@milaboratories/pl-client 3.12.0 → 3.13.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 (65) hide show
  1. package/dist/core/capabilities.cjs.map +1 -1
  2. package/dist/core/capabilities.d.ts +2 -3
  3. package/dist/core/capabilities.d.ts.map +1 -1
  4. package/dist/core/capabilities.js.map +1 -1
  5. package/dist/core/client.cjs +42 -4
  6. package/dist/core/client.cjs.map +1 -1
  7. package/dist/core/client.d.ts +21 -4
  8. package/dist/core/client.d.ts.map +1 -1
  9. package/dist/core/client.js +42 -4
  10. package/dist/core/client.js.map +1 -1
  11. package/dist/core/config.cjs +1 -0
  12. package/dist/core/config.cjs.map +1 -1
  13. package/dist/core/config.d.ts +4 -0
  14. package/dist/core/config.d.ts.map +1 -1
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/config.js.map +1 -1
  17. package/dist/core/errors.cjs +1 -0
  18. package/dist/core/errors.cjs.map +1 -1
  19. package/dist/core/errors.d.ts.map +1 -1
  20. package/dist/core/errors.js +1 -0
  21. package/dist/core/errors.js.map +1 -1
  22. package/dist/core/final.cjs +4 -1
  23. package/dist/core/final.cjs.map +1 -1
  24. package/dist/core/final.d.ts.map +1 -1
  25. package/dist/core/final.js +4 -1
  26. package/dist/core/final.js.map +1 -1
  27. package/dist/core/ll_client.cjs +47 -8
  28. package/dist/core/ll_client.cjs.map +1 -1
  29. package/dist/core/ll_client.d.ts +22 -7
  30. package/dist/core/ll_client.d.ts.map +1 -1
  31. package/dist/core/ll_client.js +48 -9
  32. package/dist/core/ll_client.js.map +1 -1
  33. package/dist/core/transaction.cjs +82 -1
  34. package/dist/core/transaction.cjs.map +1 -1
  35. package/dist/core/transaction.d.ts +52 -2
  36. package/dist/core/transaction.d.ts.map +1 -1
  37. package/dist/core/transaction.js +80 -2
  38. package/dist/core/transaction.js.map +1 -1
  39. package/dist/core/user_resources.cjs +54 -0
  40. package/dist/core/user_resources.cjs.map +1 -1
  41. package/dist/core/user_resources.d.ts +55 -1
  42. package/dist/core/user_resources.d.ts.map +1 -1
  43. package/dist/core/user_resources.js +54 -0
  44. package/dist/core/user_resources.js.map +1 -1
  45. package/dist/index.cjs +3 -0
  46. package/dist/index.d.ts +3 -3
  47. package/dist/index.js +2 -2
  48. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs +6 -6
  49. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs.map +1 -1
  50. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts +6 -6
  51. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts.map +1 -1
  52. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js +6 -6
  53. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/core/capabilities.ts +10 -3
  56. package/src/core/client.ts +69 -4
  57. package/src/core/config.test.ts +15 -0
  58. package/src/core/config.ts +6 -0
  59. package/src/core/errors.ts +2 -0
  60. package/src/core/final.ts +4 -0
  61. package/src/core/ll_client.test.ts +15 -2
  62. package/src/core/ll_client.ts +59 -9
  63. package/src/core/transaction.ts +103 -3
  64. package/src/core/user_resources.ts +85 -0
  65. package/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts +9 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-client",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "New TS/JS client for Platform API",
5
5
  "files": [
6
6
  "./dist/**/*",
@@ -30,7 +30,7 @@
30
30
  "undici": "~7.16.0",
31
31
  "utility-types": "^3.11.0",
32
32
  "yaml": "^2.8.0",
33
- "@milaboratories/pl-model-common": "1.46.2",
33
+ "@milaboratories/pl-model-common": "1.46.3",
34
34
  "@milaboratories/pl-http": "1.2.4",
35
35
  "@milaboratories/ts-helpers": "1.8.3"
36
36
  },
@@ -41,9 +41,9 @@
41
41
  "openapi-typescript": "^7.10.0",
42
42
  "typescript": "~5.9.3",
43
43
  "vitest": "^4.1.3",
44
- "@milaboratories/build-configs": "2.0.0",
45
44
  "@milaboratories/ts-builder": "1.6.0",
46
- "@milaboratories/ts-configs": "1.3.0"
45
+ "@milaboratories/ts-configs": "1.3.0",
46
+ "@milaboratories/build-configs": "2.0.0"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=22.19.0"
@@ -5,8 +5,7 @@
5
5
  * to detect incompatibilities between blocks and backend via
6
6
  * via `TemplateDataV3.requiredCapabilities`.
7
7
  *
8
- * Mirror of `pl/platform/api/plapiserver/server_capabilities.go` — keep
9
- * tokens in sync with the backend's advertised list. Format is
8
+ * Keep tokens in sync with the backend's advertised list. Format is
10
9
  * `<feature>:<version>`; bump the version when wire semantics change.
11
10
  *
12
11
  * Shared across:
@@ -15,7 +14,15 @@
15
14
  * - `@platforma-sdk/block-tools` — copies it onto the published manifest
16
15
  * - `@milaboratories/pl-middle-layer` — checks it at install time
17
16
  */
18
- export type BackendCapability = "auth:v2" | "treeFilter:v2" | "wasm:v1";
17
+ export type BackendCapability =
18
+ | "auth:v2"
19
+ | "treeFilter:v2"
20
+ | "wasm:v1"
21
+ // Project-sharing capability tokens.
22
+ | "crossTreeRefs:v1" // cross-color field attach (accept a foreign-colored shared envelope)
23
+ | "userListing:v1" // list users for the recipient picker
24
+ | "publicGrants:v1" // public (everyone) grants allowed for any role
25
+ | "txListGrants:v1"; // list grants inside a transaction (batched recipient reads)
19
26
 
20
27
  /** True iff `capabilities` advertises the requested token. */
21
28
  export function hasCapability(
@@ -2,6 +2,7 @@ import type { AuthOps, PlClientConfig, PlConnectionStatusListener, wireProtocol
2
2
  import type { PlCallOps } from "./ll_client";
3
3
  import { LLPlClient } from "./ll_client";
4
4
  import { PlTransaction, TxCommitConflict } from "./transaction";
5
+ import type { Role } from "./transaction";
5
6
  import type { OptionalSignedResourceId, SignedResourceId } from "./types";
6
7
  import {
7
8
  ensureSignedResourceIdNotNull,
@@ -80,6 +81,11 @@ export class PlClient {
80
81
 
81
82
  private _userResources?: UserResources;
82
83
 
84
+ /** Cached effective role of the authenticated session, fetched once during
85
+ * init via the GetSessionInfo RPC. `null` for an anonymous (no-auth)
86
+ * connection — mirrors {@link authUser}. Read through {@link currentUserRole}. */
87
+ private _currentUserRole: Role | null = null;
88
+
83
89
  private _txCommittedStat: TxStat = initialTxStat();
84
90
  private _txConflictStat: TxStat = initialTxStat();
85
91
  private _txErrorStat: TxStat = initialTxStat();
@@ -106,6 +112,15 @@ export class PlClient {
106
112
  const conf =
107
113
  typeof configOrAddress === "string" ? plAddressToConfig(configOrAddress) : configOrAddress;
108
114
 
115
+ // An empty or whitespace-only asUser means "no impersonation"; normalize to undefined so a
116
+ // directly-constructed { asUser: "" } does not slip through as an empty login.
117
+ if (conf.asUser !== undefined && conf.asUser.trim() === "") conf.asUser = undefined;
118
+
119
+ // asUser (impersonation) and alternativeRoot both repoint the client root, so they cannot be
120
+ // combined. Validate here, before init() touches the network, so a misconfiguration fails fast.
121
+ if (conf.asUser !== undefined && conf.alternativeRoot !== undefined)
122
+ throw new Error("PlClient: asUser and alternativeRoot cannot be combined.");
123
+
109
124
  this.buildLLPlClient = async (
110
125
  shouldUseGzip: boolean,
111
126
  wireProtocol?: wireProtocol,
@@ -232,10 +247,24 @@ export class PlClient {
232
247
  return this._ll!.hasCapability(capability);
233
248
  }
234
249
 
250
+ /** Effective role of the authenticated session, resolved once during init via
251
+ * the GetSessionInfo RPC and cached. `null` for an anonymous (no-auth)
252
+ * connection, or when the backend predates GetSessionInfo. Mirrors
253
+ * {@link authUser}'s null contract. */
254
+ public get currentUserRole(): Role | null {
255
+ this.checkInitialized();
256
+ return this._currentUserRole;
257
+ }
258
+
259
+ /** Login of the authenticated user, or `null` for an anonymous connection. */
260
+ public get authUser(): string | null {
261
+ this.checkInitialized();
262
+ return this.userResources.authUser;
263
+ }
264
+
235
265
  /**
236
- * True if the backend honors per-file `permissions` on workdir fill rules
237
- * (PR #1830 in milaboratory/pl). See `LLPlClient.supportsWritableWorkdirFiles`
238
- * for the full definition.
266
+ * True if the backend honors per-file `permissions` on workdir fill rules.
267
+ * See {@link LLPlClient.supportsWritableWorkdirFiles} for the full definition.
239
268
  */
240
269
  public get supportsWritableWorkdirFiles(): boolean {
241
270
  this.checkInitialized();
@@ -274,7 +303,27 @@ export class PlClient {
274
303
  this._ll = await this.buildLLPlClient(true, wireProtocol);
275
304
  }
276
305
 
277
- const userRoot = await this.userResources.getUserRoot({ createIfNotExists: true });
306
+ // Resolve the client root. Normally the caller's own root; when `asUser` is set (admin impersonation),
307
+ // the target user's root instead. The backend authorizes impersonation by role and silently returns the
308
+ // caller's own root for non-admins, so no client-side role gate is needed here.
309
+ let userRoot: SignedResourceId;
310
+ if (this.conf.asUser === undefined) {
311
+ userRoot = await this.userResources.getUserRoot({ createIfNotExists: true });
312
+ } else {
313
+ const impersonatedRoot = await this.userResources.getUserRoot({ login: this.conf.asUser });
314
+ if (impersonatedRoot === undefined)
315
+ throw new Error(
316
+ `PlClient: cannot open root of user '${this.conf.asUser}': no root found (the user may have never logged in).`,
317
+ );
318
+ userRoot = impersonatedRoot;
319
+ }
320
+
321
+ // Resolve the caller's role once; only the publicGrants-gated share-with-everybody check uses it,
322
+ // so skip it for anonymous connections or when publicGrants:v1 is absent. Use _ll.hasCapability,
323
+ // not this.hasCapability: we are mid-init, so this.checkInitialized() would throw.
324
+ if (this.userResources.authUser !== null && this._ll.hasCapability("publicGrants:v1")) {
325
+ this._currentUserRole = (await this._ll.getSessionInfo()).role;
326
+ }
278
327
 
279
328
  if (this.conf.alternativeRoot === undefined) {
280
329
  this._clientRoot = userRoot;
@@ -421,6 +470,22 @@ export class PlClient {
421
470
  return await this.withTx(name, true, body, { ...ops, ...defaultTxOps });
422
471
  }
423
472
 
473
+ /**
474
+ * Runs a write transaction whose default color is `root`'s signature instead of the client
475
+ * root's, and whose `tx.clientRoot` is `root`. For admin cross-root operations (e.g. copying a
476
+ * project into another user's root so the copy is minted in that user's color). The backend
477
+ * still authorizes the write by role.
478
+ */
479
+ public async withWriteTxOnRoot<T>(
480
+ root: SignedResourceId,
481
+ name: string,
482
+ body: (tx: PlTransaction) => Promise<T>,
483
+ ops: Partial<TxOps> = {},
484
+ ): Promise<T> {
485
+ this.checkInitialized();
486
+ return await this._withTx(name, true, root, body, { ...ops, ...defaultTxOps });
487
+ }
488
+
424
489
  public async withReadTx<T>(
425
490
  name: string,
426
491
  body: (tx: PlTransaction) => Promise<T>,
@@ -73,6 +73,21 @@ test("should retain non-default port for https URL", () => {
73
73
  expect(config.ssl).toBe(true);
74
74
  });
75
75
 
76
+ test("asUser is undefined when as-user param is absent", () => {
77
+ const conf = plAddressToConfig("http://127.0.0.1:6345");
78
+ expect(conf.asUser).toBeUndefined();
79
+ });
80
+
81
+ test("asUser is undefined when as-user param is empty", () => {
82
+ const conf = plAddressToConfig("http://127.0.0.1:6345/?as-user=");
83
+ expect(conf.asUser).toBeUndefined();
84
+ });
85
+
86
+ test("asUser is parsed from the as-user url param", () => {
87
+ const conf = plAddressToConfig("http://127.0.0.1:6345/?as-user=alice");
88
+ expect(conf.asUser).toEqual("alice");
89
+ });
90
+
76
91
  test("should throw an error for grpc URL without an explicit port", () => {
77
92
  expect(() => plAddressToConfig("grpc://example.com")).toThrow(
78
93
  "Port must be specified explicitly for grpc: protocol.",
@@ -16,6 +16,11 @@ export interface PlClientConfig {
16
16
  * client root. */
17
17
  alternativeRoot?: string;
18
18
 
19
+ /** Admin-only impersonation: if set, the client opens the root of the user with this login instead of the
20
+ * caller's own root. The backend authorizes this by role and silently falls back to the caller's own root for
21
+ * regular users, so it is safe without a client-side gate. Mutually exclusive with {@link alternativeRoot}. */
22
+ asUser?: string;
23
+
19
24
  /** If true, client will establish tls connection to the server, using default
20
25
  * CA of node instance. */
21
26
  // Not implementing custom ssl validation logic for now.
@@ -190,6 +195,7 @@ export function plAddressToConfig(
190
195
  return {
191
196
  hostAndPort: `${url.hostname}:${port}`,
192
197
  alternativeRoot: url.searchParams.get("alternative-root") ?? undefined,
198
+ asUser: url.searchParams.get("as-user") || undefined,
193
199
  ssl: url.protocol === "https:" || url.protocol === "tls:",
194
200
 
195
201
  wireProtocol: (url.searchParams.get("wire-protocol") as wireProtocol) ?? undefined,
@@ -20,6 +20,8 @@ export function isUnauthenticated(err: unknown, nested: boolean = false): boolea
20
20
  if ((err as any).name == "RpcError" && (err as any).code == "UNAUTHENTICATED") return true;
21
21
  if ((err as any).name == "RESTError" && (err as any).status.code == Code.UNAUTHENTICATED)
22
22
  return true;
23
+ // PlError (e.g. UnrecoverablePlError from a failed streaming tx) carries the status code numerically.
24
+ if (err instanceof PlError && err.status?.code === Code.UNAUTHENTICATED) return true;
23
25
  if ((err as any).cause !== undefined && !nested)
24
26
  return isUnauthenticated((err as any).cause, true);
25
27
  return false;
package/src/core/final.ts CHANGED
@@ -87,6 +87,10 @@ export const DefaultFinalResourceDataPredicate: FinalResourceDataPredicate = (r)
87
87
  case ResourceTypeName.UserProject:
88
88
  case ResourceTypeName.Projects:
89
89
  case ResourceTypeName.ClientRoot:
90
+ // Never final — these sharing resources gain and lose dynamic child fields over their lifetime.
91
+ case ResourceTypeName.SharingOutbox:
92
+ case ResourceTypeName.SharingState:
93
+ case ResourceTypeName.SharedEnvelope:
90
94
  return false;
91
95
  default:
92
96
  if (
@@ -50,7 +50,15 @@ test("unauthenticated status change", async () => {
50
50
  return;
51
51
  }
52
52
 
53
- const client = await LLPlClient.build(plAddressToTestConfig(cfg.address));
53
+ // The flip to Unauthenticated is async, so await it via the status listener.
54
+ let onUnauthenticated!: () => void;
55
+ const unauthenticated = new Promise<void>((resolve) => (onUnauthenticated = resolve));
56
+
57
+ const client = await LLPlClient.build(plAddressToTestConfig(cfg.address), {
58
+ statusListener: (s) => {
59
+ if (s === "Unauthenticated") onUnauthenticated();
60
+ },
61
+ });
54
62
  expect(client.status).toBe("OK");
55
63
 
56
64
  const tx = client.createTx(true);
@@ -73,7 +81,12 @@ test("unauthenticated status change", async () => {
73
81
  await tx.await();
74
82
  }).rejects.toThrow(UnauthenticatedError);
75
83
 
76
- await tp.setImmediate();
84
+ await Promise.race([
85
+ unauthenticated,
86
+ tp
87
+ .setTimeout(5000)
88
+ .then(() => Promise.reject(new Error("status never became Unauthenticated"))),
89
+ ]);
77
90
 
78
91
  expect(client.status).toEqual("Unauthenticated");
79
92
  });
@@ -42,7 +42,7 @@ import {
42
42
  TxAPI_ServerMessage,
43
43
  } from "../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api";
44
44
  import type { MiLogger } from "@milaboratories/ts-helpers";
45
- import { isAbortedError } from "./errors";
45
+ import { isAbortedError, isUnauthenticated } from "./errors";
46
46
  import { Timestamp } from "../proto-grpc/google/protobuf/timestamp";
47
47
 
48
48
  export interface PlCallOps {
@@ -122,6 +122,7 @@ export class LLPlClient implements WireClientProviderFactory {
122
122
  private _authMethodsSync?: grpcTypes.AuthAPI_ListMethods_Response;
123
123
 
124
124
  private _status: PlConnectionStatus = "OK";
125
+ private authProbeInFlight = false;
125
126
  private readonly statusListener?: PlConnectionStatusListener;
126
127
 
127
128
  private _wireProto: wireProtocol = "grpc";
@@ -393,6 +394,25 @@ export class LLPlClient implements WireClientProviderFactory {
393
394
  });
394
395
  }
395
396
 
397
+ /**
398
+ * UNAUTHENTICATED covers both a dead session (must reset) and a per-request authorization failure
399
+ * such as a revoked resource signature (must not). Disambiguate by probing getSessionInfo — an
400
+ * authenticated call with no resource signature: success means the session is alive and the failure
401
+ * was resource-level; a probe that is itself Unauthenticated means real auth loss. Coalesced via
402
+ * authProbeInFlight so a burst of failures costs one probe.
403
+ */
404
+ private async verifyThenMaybeEscalateUnauthenticated(): Promise<void> {
405
+ if (this._status !== "OK" || this.authProbeInFlight) return;
406
+ this.authProbeInFlight = true;
407
+ try {
408
+ await this.getSessionInfo();
409
+ } catch (e) {
410
+ if (isUnauthenticated(e)) this.updateStatus("Unauthenticated");
411
+ } finally {
412
+ this.authProbeInFlight = false;
413
+ }
414
+ }
415
+
396
416
  public get status(): PlConnectionStatus {
397
417
  return this._status;
398
418
  }
@@ -458,7 +478,7 @@ export class LLPlClient implements WireClientProviderFactory {
458
478
  }
459
479
 
460
480
  if (respErr.error.code === Code.UNAUTHENTICATED) {
461
- this.updateStatus("Unauthenticated");
481
+ void this.verifyThenMaybeEscalateUnauthenticated();
462
482
  }
463
483
 
464
484
  // Let later middleware to deal with standard gRPC error.
@@ -476,7 +496,7 @@ export class LLPlClient implements WireClientProviderFactory {
476
496
  onReceiveStatus: (status, next) => {
477
497
  if (status.code == GrpcStatus.UNAUTHENTICATED)
478
498
  // (!!!) don't change to "==="
479
- this.updateStatus("Unauthenticated");
499
+ void this.verifyThenMaybeEscalateUnauthenticated();
480
500
  if (status.code == GrpcStatus.UNAVAILABLE)
481
501
  // (!!!) don't change to "==="
482
502
  this.updateStatus("Disconnected");
@@ -742,13 +762,12 @@ export class LLPlClient implements WireClientProviderFactory {
742
762
  }
743
763
 
744
764
  /**
745
- * True if the backend honors per-file `permissions` on workdir fill rules
746
- * (PR #1830 in milaboratory/pl). Backends before this change ignore the
747
- * requested mode and always land files at the canonical archive perm,
748
- * making `exec.builder().writeFile/addFile({ writable: true })` a no-op.
765
+ * True if the backend honors per-file `permissions` on workdir fill rules, so
766
+ * `exec.builder().writeFile/addFile({ writable: true })` takes effect. When false
767
+ * the requested mode is ignored and files land at the canonical archive perm.
749
768
  *
750
- * Tagged at 3.5.0 cut without the change, so [3, 5, 0] excludes the tagged
751
- * release but includes dev builds past the tag (e.g. "3.5.0-224-g0ca182").
769
+ * [3, 5, 0] excludes the tagged 3.5.0 release but includes dev builds past the
770
+ * tag (e.g. "3.5.0-224-g0ca182"), which is where the support lands.
752
771
  */
753
772
  public get supportsWritableWorkdirFiles(): boolean {
754
773
  return isAfterVersion(this.serverInfo.coreVersion, [3, 5, 0]);
@@ -931,6 +950,37 @@ export class LLPlClient implements WireClientProviderFactory {
931
950
  return responses;
932
951
  }
933
952
 
953
+ /** Returns the authenticated caller's session id and effective role.
954
+ * Models the {@link getUserRoot} wrapper: gRPC unary with a REST fallback. */
955
+ public async getSessionInfo(): Promise<grpcTypes.AuthAPI_GetSessionInfo_Response> {
956
+ const cl = this.clientProvider.get();
957
+ if (cl instanceof GrpcPlApiClient) {
958
+ return (await cl.getSessionInfo({})).response;
959
+ } else {
960
+ const resp = notEmpty(
961
+ (await cl.POST("/v1/auth/session-info", { body: {} })).data,
962
+ "REST: empty response for getSessionInfo request",
963
+ );
964
+ return {
965
+ sessionId: Uint8Array.from(Buffer.from(resp.sessionId, "base64")),
966
+ role: resp.role as AuthAPI_Role,
967
+ };
968
+ }
969
+ }
970
+
971
+ /** Lists the users known to the server — the recipient picker's source. A user
972
+ * becomes known on first login; provisioned users who have never logged in do not
973
+ * appear. gRPC-only (unary, no REST binding). */
974
+ public async listUsers(): Promise<grpcTypes.AuthAPI_User[]> {
975
+ const cl = this.clientProvider.get();
976
+
977
+ if (!(cl instanceof GrpcPlApiClient)) {
978
+ throw new Error("ListUsers requires gRPC wire protocol; REST is not supported");
979
+ }
980
+
981
+ return (await cl.listUsers({})).response.users;
982
+ }
983
+
934
984
  public async txSync(txId: bigint): Promise<void> {
935
985
  const cl = this.clientProvider.get();
936
986
  if (cl instanceof GrpcPlApiClient) {
@@ -1,5 +1,3 @@
1
- // TODO: fix this
2
- /* eslint-disable no-prototype-builtins */
3
1
  import type {
4
2
  ColorProof,
5
3
  LocalResourceId,
@@ -31,10 +29,15 @@ import type {
31
29
  ServerMessageResponse,
32
30
  } from "./ll_transaction";
33
31
  import type {
32
+ AuthAPI_Grant,
34
33
  ResourceAPI_Tree_Filter,
35
34
  ResourceAPI_Tree_SeedResource,
36
35
  } from "../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api";
37
- import { TxAPI_Open_Request_WritableTx } from "../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api";
36
+ import {
37
+ TxAPI_Open_Request_WritableTx,
38
+ AuthAPI_GrantAccess_GrantType,
39
+ AuthAPI_Role,
40
+ } from "../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api";
38
41
  import type { NonUndefined } from "utility-types";
39
42
  import { toBytes } from "../util/util";
40
43
  import {
@@ -155,6 +158,42 @@ export interface ResourceIdWithSignature {
155
158
  resourceSignature: ResourceSignature;
156
159
  }
157
160
 
161
+ /** Access level handed to a grant recipient (see {@link PlTransaction.grantAccess}). */
162
+ export interface GrantPermissions {
163
+ /** Write access = ability to modify the resource subtree + re-share it. */
164
+ writable: boolean;
165
+ }
166
+
167
+ /**
168
+ * Distinguishes a regular user-to-user grant from a system-level public grant.
169
+ * Re-export of the wire enum so callers need not reach into the generated proto.
170
+ */
171
+ export const GrantType = AuthAPI_GrantAccess_GrantType;
172
+ export type GrantType = AuthAPI_GrantAccess_GrantType;
173
+
174
+ /**
175
+ * Effective role of an authenticated session (controller / workflow / user / admin).
176
+ * Re-export of the wire enum so callers need not reach into the generated proto.
177
+ * Returned by {@link LLPlClient.getSessionInfo} and surfaced as
178
+ * {@link PlClient.currentUserRole}.
179
+ */
180
+ export const Role = AuthAPI_Role;
181
+ export type Role = AuthAPI_Role;
182
+
183
+ /**
184
+ * Recognises the "all users on the server" sentinel login. A public
185
+ * ({@link GrantType.ANY_AUTHORISED}) grant is recorded against a reserved login, and
186
+ * recipients of an everyone-grant surface in `ListGrants` with that value — callers
187
+ * detect it here and map it to "*".
188
+ *
189
+ * Matched by exact token equality. The long random token guarantees no real user login
190
+ * collides with it.
191
+ */
192
+ const EveryoneUser = "everyone-Po9ahwahxai7Aejingaiyiequuecu3ei4moaNge4xahTh0Co7XeeLeiph6ahy3As";
193
+ export function isEveryoneUserLogin(login: string): boolean {
194
+ return login === EveryoneUser;
195
+ }
196
+
158
197
  const emptySignature = toResourceSignature(new Uint8Array(0));
159
198
 
160
199
  function toResourceIdAndSignature(ref: AnyResourceRef): ResourceIdWithSignature {
@@ -817,6 +856,67 @@ export class PlTransaction {
817
856
  });
818
857
  }
819
858
 
859
+ /**
860
+ * Grant another user access to a resource (and its subtree). Used by the
861
+ * project-sharing donor flow to hand a recipient the shared envelope.
862
+ *
863
+ * `grantType` defaults to a regular user-to-user grant
864
+ * ({@link GrantType.SINGLE_USER}); pass {@link GrantType.ANY_AUTHORISED}
865
+ * to make the resource public to everyone (requires backend `publicGrants:v1`
866
+ * and a role allowed to grant to everyone — `target` is ignored / rewritten
867
+ * to the everyone-user by the backend).
868
+ */
869
+ public grantAccess(
870
+ rId: AnyResourceRef,
871
+ target: string,
872
+ permissions: GrantPermissions,
873
+ grantType: GrantType = AuthAPI_GrantAccess_GrantType.SINGLE_USER,
874
+ ): void {
875
+ this.sendVoidAsync({
876
+ oneofKind: "grantAccess",
877
+ grantAccess: {
878
+ ...this.toSignedResourceId(rId),
879
+ targetUser: target,
880
+ permissions,
881
+ grantType,
882
+ },
883
+ });
884
+ }
885
+
886
+ /**
887
+ * Revoke a single user's grant on a resource — the inverse of {@link grantAccess}.
888
+ * Used by the sharing donor flow to pull one recipient out of a multi-recipient
889
+ * envelope without tearing the whole share down. A no-op on the backend if the user
890
+ * has no grant.
891
+ */
892
+ public revokeAccess(rId: AnyResourceRef, target: string): void {
893
+ this.sendVoidAsync({
894
+ oneofKind: "revokeAccess",
895
+ revokeAccess: {
896
+ ...this.toSignedResourceId(rId),
897
+ targetUser: target,
898
+ },
899
+ });
900
+ }
901
+
902
+ /**
903
+ * Enumerate the grants of a resource — the donor-side "who did I share with" view.
904
+ * The backend gates this on the signed, writable resource handle, so only the
905
+ * resource's owner can read its grants. Recipients of a public
906
+ * ({@link GrantType.ANY_AUTHORISED}) grant surface with `user` matching the
907
+ * everyone-sentinel — test with {@link isEveryoneUserLogin}.
908
+ *
909
+ * Transactional unary op (single response with all grants), so it rides the tx
910
+ * channel and works on every wire protocol — unlike the standalone server-streaming
911
+ * `ListGrants` RPC, which has no REST binding.
912
+ */
913
+ public listGrants(rId: AnyResourceRef): Promise<AuthAPI_Grant[]> {
914
+ return this.sendSingleAndParse(
915
+ { oneofKind: "listGrants", listGrants: this.toSignedResourceId(rId) },
916
+ (r) => r.listGrants.grants,
917
+ );
918
+ }
919
+
820
920
  //
821
921
  // Fields
822
922
  //
@@ -16,6 +16,26 @@ const AnonymousClientRoot = "AnonymousRoot";
16
16
  const LsStorageTypePrefix = "LS/"; // implements ls API in particular storage
17
17
  const LsProviderFieldPrefix = "storage/"; // provides access to storages list
18
18
 
19
+ /** One recipient of a resource, as returned by {@link UserResources.listGrants}. */
20
+ export interface GrantEntry {
21
+ /** Login the grant targets. An everyone-grant surfaces here as the backend's
22
+ * everyone-sentinel — test with {@link isEveryoneUserLogin} (from "./transaction"); callers
23
+ * map it to "*". */
24
+ readonly user: string;
25
+ /** True for a writable grant (copy / collaboration), false for read-only. */
26
+ readonly writable: boolean;
27
+ /** Login of the user who created the grant. */
28
+ readonly grantedBy: string;
29
+ /** When the grant was created (ms epoch). */
30
+ readonly grantedAt: number;
31
+ }
32
+
33
+ /** One user known to the server, as returned by {@link UserResources.listUsers}. */
34
+ export interface UserEntry {
35
+ /** Stable identifier of the user; the grant target and `GetUserRoot` key. */
36
+ readonly login: string;
37
+ }
38
+
19
39
  /** Information about a single data library (LS storage). */
20
40
  export interface StorageInfo {
21
41
  /** Machine-stable identifier, e.g. "library". Used for filtering and map keys. */
@@ -172,6 +192,71 @@ export class UserResources {
172
192
  return await this.getDataLibrariesViaLegacy();
173
193
  }
174
194
 
195
+ /**
196
+ * Discovers shared resources of a given type granted to the user, as signed resource ids.
197
+ *
198
+ * The public, type-filtered form of {@link getDataLibrariesViaList}: it polls the
199
+ * `ListUserResources` gRPC stream and returns every `sharedResource` entry whose
200
+ * `resourceType.name` matches `resourceTypeName`. Matching is by name only — the
201
+ * version is ignored (permissive, survives schema bumps).
202
+ *
203
+ * gRPC-only: `ListUserResources` throws on a REST-connected client
204
+ * ({@link LLPlClient.listUserResources}), so callers on REST get a thrown error.
205
+ */
206
+ async listSharedResourcesByType(
207
+ resourceTypeName: string,
208
+ opts: { login?: string } = {},
209
+ ): Promise<SignedResourceId[]> {
210
+ const responses = await this.ll.listUserResources({ login: opts.login });
211
+
212
+ const result: SignedResourceId[] = [];
213
+ for (const msg of responses) {
214
+ if (msg.entry.oneofKind !== "sharedResource") continue;
215
+ const sr = msg.entry.sharedResource;
216
+ if (!sr.resourceType) continue;
217
+ if (sr.resourceType.name !== resourceTypeName) continue;
218
+ result.push(createSignedResourceId(sr.resourceId, toResourceSignature(sr.resourceSignature)));
219
+ }
220
+ return result;
221
+ }
222
+
223
+ /**
224
+ * Enumerates the recipients of a single resource — the donor-side "who did I
225
+ * share with" view.
226
+ *
227
+ * Takes a signed, writable resource handle: the backend gates grant listing on a
228
+ * signed resource id with writable permission, so only the resource's owner can
229
+ * call it. An everyone-grant surfaces with `user` matching the backend's
230
+ * everyone-sentinel — test with {@link isEveryoneUserLogin} (from "./transaction");
231
+ * the caller maps that to "*".
232
+ *
233
+ * Runs over a read transaction ({@link PlTransaction.listGrants}), so it works on
234
+ * every wire protocol — including REST, where the standalone server-streaming
235
+ * `ListGrants` RPC has no binding.
236
+ */
237
+ async listGrants(resourceId: SignedResourceId): Promise<GrantEntry[]> {
238
+ const grants = await this.runTx("ListGrants", false, NullSignedResourceId, (tx) =>
239
+ tx.listGrants(resourceId),
240
+ );
241
+ return grants.map((grant) => ({
242
+ user: grant.user,
243
+ writable: grant.permissions?.writable ?? false,
244
+ grantedBy: grant.grantedBy,
245
+ grantedAt: Number(grant.grantedAt),
246
+ }));
247
+ }
248
+
249
+ /**
250
+ * Lists the logins of users known to the server, for the recipient picker. A user becomes
251
+ * known on first login; provisioned users who have never logged in do not appear. gRPC-only
252
+ * (the underlying {@link LLPlClient.listUsers} has no REST binding) — throws on a
253
+ * REST-connected client, so callers gate on the `userListing:v1` capability.
254
+ */
255
+ async listUsers(): Promise<UserEntry[]> {
256
+ const users = await this.ll.listUsers();
257
+ return users.map((user) => ({ login: user.login }));
258
+ }
259
+
175
260
  private async getUserRootViaRpc(opts: {
176
261
  login?: string;
177
262
  createIfNotExists: true;
@@ -3938,7 +3938,7 @@ export interface AuthAPI_GrantAccess_Request {
3938
3938
  /**
3939
3939
  * @generated from protobuf field: MiLaboratories.PL.API.AuthAPI.GrantAccess.GrantType grant_type = 5
3940
3940
  */
3941
- grantType: AuthAPI_GrantAccess_GrantType; // default: GENERAL
3941
+ grantType: AuthAPI_GrantAccess_GrantType; // default: SINGLE_USER
3942
3942
  }
3943
3943
  /**
3944
3944
  * @generated from protobuf message MiLaboratories.PL.API.AuthAPI.GrantAccess.Response
@@ -3952,17 +3952,17 @@ export interface AuthAPI_GrantAccess_Response {
3952
3952
  */
3953
3953
  export enum AuthAPI_GrantAccess_GrantType {
3954
3954
  /**
3955
- * regular user-to-user grant (default)
3955
+ * grants access for single user (default).
3956
3956
  *
3957
- * @generated from protobuf enum value: GENERAL = 0;
3957
+ * @generated from protobuf enum value: SINGLE_USER = 0;
3958
3958
  */
3959
- GENERAL = 0,
3959
+ SINGLE_USER = 0,
3960
3960
  /**
3961
- * make resource publically-accessible, showing it in ListUserResources for all users. Requires controller role.
3961
+ * makes resource publically-accessible, showing it in ListUserResources for all authorised clients.
3962
3962
  *
3963
- * @generated from protobuf enum value: MAKE_RESOURCE_PUBLIC = 1;
3963
+ * @generated from protobuf enum value: ANY_AUTHORISED = 1;
3964
3964
  */
3965
- MAKE_RESOURCE_PUBLIC = 1
3965
+ ANY_AUTHORISED = 1
3966
3966
  }
3967
3967
  /**
3968
3968
  * @generated from protobuf message MiLaboratories.PL.API.AuthAPI.RevokeAccess
@@ -4034,11 +4034,11 @@ export interface AuthAPI_Grant {
4034
4034
  /**
4035
4035
  * @generated from protobuf field: string user = 1
4036
4036
  */
4037
- user: string;
4037
+ user: string; // grantee
4038
4038
  /**
4039
4039
  * @generated from protobuf field: uint64 resource_id = 2
4040
4040
  */
4041
- resourceId: bigint;
4041
+ resourceId: bigint; // target resource of a grant (what grantee can access)
4042
4042
  /**
4043
4043
  * @generated from protobuf field: MiLaboratories.PL.API.AuthAPI.Grant.Permissions permissions = 3
4044
4044
  */