@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.
- package/dist/core/capabilities.cjs.map +1 -1
- package/dist/core/capabilities.d.ts +2 -3
- package/dist/core/capabilities.d.ts.map +1 -1
- package/dist/core/capabilities.js.map +1 -1
- package/dist/core/client.cjs +42 -4
- package/dist/core/client.cjs.map +1 -1
- package/dist/core/client.d.ts +21 -4
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +42 -4
- package/dist/core/client.js.map +1 -1
- package/dist/core/config.cjs +1 -0
- package/dist/core/config.cjs.map +1 -1
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +1 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.cjs +1 -0
- package/dist/core/errors.cjs.map +1 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +1 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/final.cjs +4 -1
- package/dist/core/final.cjs.map +1 -1
- package/dist/core/final.d.ts.map +1 -1
- package/dist/core/final.js +4 -1
- package/dist/core/final.js.map +1 -1
- package/dist/core/ll_client.cjs +47 -8
- package/dist/core/ll_client.cjs.map +1 -1
- package/dist/core/ll_client.d.ts +22 -7
- package/dist/core/ll_client.d.ts.map +1 -1
- package/dist/core/ll_client.js +48 -9
- package/dist/core/ll_client.js.map +1 -1
- package/dist/core/transaction.cjs +82 -1
- package/dist/core/transaction.cjs.map +1 -1
- package/dist/core/transaction.d.ts +52 -2
- package/dist/core/transaction.d.ts.map +1 -1
- package/dist/core/transaction.js +80 -2
- package/dist/core/transaction.js.map +1 -1
- package/dist/core/user_resources.cjs +54 -0
- package/dist/core/user_resources.cjs.map +1 -1
- package/dist/core/user_resources.d.ts +55 -1
- package/dist/core/user_resources.d.ts.map +1 -1
- package/dist/core/user_resources.js +54 -0
- package/dist/core/user_resources.js.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs +6 -6
- package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs.map +1 -1
- package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts +6 -6
- package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts.map +1 -1
- package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js +6 -6
- package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js.map +1 -1
- package/package.json +4 -4
- package/src/core/capabilities.ts +10 -3
- package/src/core/client.ts +69 -4
- package/src/core/config.test.ts +15 -0
- package/src/core/config.ts +6 -0
- package/src/core/errors.ts +2 -0
- package/src/core/final.ts +4 -0
- package/src/core/ll_client.test.ts +15 -2
- package/src/core/ll_client.ts +59 -9
- package/src/core/transaction.ts +103 -3
- package/src/core/user_resources.ts +85 -0
- 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.
|
|
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.
|
|
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"
|
package/src/core/capabilities.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* to detect incompatibilities between blocks and backend via
|
|
6
6
|
* via `TemplateDataV3.requiredCapabilities`.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
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 =
|
|
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(
|
package/src/core/client.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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>,
|
package/src/core/config.test.ts
CHANGED
|
@@ -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.",
|
package/src/core/config.ts
CHANGED
|
@@ -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,
|
package/src/core/errors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
});
|
package/src/core/ll_client.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
* (
|
|
747
|
-
* requested mode and
|
|
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
|
-
*
|
|
751
|
-
*
|
|
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) {
|
package/src/core/transaction.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
*
|
|
3955
|
+
* grants access for single user (default).
|
|
3956
3956
|
*
|
|
3957
|
-
* @generated from protobuf enum value:
|
|
3957
|
+
* @generated from protobuf enum value: SINGLE_USER = 0;
|
|
3958
3958
|
*/
|
|
3959
|
-
|
|
3959
|
+
SINGLE_USER = 0,
|
|
3960
3960
|
/**
|
|
3961
|
-
*
|
|
3961
|
+
* makes resource publically-accessible, showing it in ListUserResources for all authorised clients.
|
|
3962
3962
|
*
|
|
3963
|
-
* @generated from protobuf enum value:
|
|
3963
|
+
* @generated from protobuf enum value: ANY_AUTHORISED = 1;
|
|
3964
3964
|
*/
|
|
3965
|
-
|
|
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
|
*/
|