@milaboratories/pl-client 3.14.7 → 3.16.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 (114) hide show
  1. package/dist/core/PromiseTracker.d.ts +1 -2
  2. package/dist/core/PromiseTracker.d.ts.map +1 -1
  3. package/dist/core/abstract_stream.d.ts +2 -3
  4. package/dist/core/abstract_stream.d.ts.map +1 -1
  5. package/dist/core/auth.d.ts +2 -4
  6. package/dist/core/auth.d.ts.map +1 -1
  7. package/dist/core/cache.d.ts +1 -3
  8. package/dist/core/cache.d.ts.map +1 -1
  9. package/dist/core/capabilities.d.ts +2 -3
  10. package/dist/core/capabilities.d.ts.map +1 -1
  11. package/dist/core/client.cjs +7 -0
  12. package/dist/core/client.cjs.map +1 -1
  13. package/dist/core/client.d.ts +8 -5
  14. package/dist/core/client.d.ts.map +1 -1
  15. package/dist/core/client.js +7 -0
  16. package/dist/core/client.js.map +1 -1
  17. package/dist/core/config.d.ts +29 -31
  18. package/dist/core/config.d.ts.map +1 -1
  19. package/dist/core/default_client.d.ts +2 -4
  20. package/dist/core/default_client.d.ts.map +1 -1
  21. package/dist/core/driver.d.ts +4 -6
  22. package/dist/core/driver.d.ts.map +1 -1
  23. package/dist/core/errors.d.ts +20 -22
  24. package/dist/core/errors.d.ts.map +1 -1
  25. package/dist/core/final.d.ts +4 -5
  26. package/dist/core/final.d.ts.map +1 -1
  27. package/dist/core/ll_client.cjs +45 -18
  28. package/dist/core/ll_client.cjs.map +1 -1
  29. package/dist/core/ll_client.d.ts +21 -10
  30. package/dist/core/ll_client.d.ts.map +1 -1
  31. package/dist/core/ll_client.js +45 -18
  32. package/dist/core/ll_client.js.map +1 -1
  33. package/dist/core/ll_transaction.d.ts +4 -6
  34. package/dist/core/ll_transaction.d.ts.map +1 -1
  35. package/dist/core/stat.d.ts +2 -3
  36. package/dist/core/stat.d.ts.map +1 -1
  37. package/dist/core/transaction.cjs +2 -1
  38. package/dist/core/transaction.cjs.map +1 -1
  39. package/dist/core/transaction.d.ts +30 -32
  40. package/dist/core/transaction.d.ts.map +1 -1
  41. package/dist/core/transaction.js +2 -1
  42. package/dist/core/transaction.js.map +1 -1
  43. package/dist/core/tree_filter.d.ts +14 -10
  44. package/dist/core/tree_filter.d.ts.map +1 -1
  45. package/dist/core/types.d.ts +54 -55
  46. package/dist/core/types.d.ts.map +1 -1
  47. package/dist/core/unauth_client.cjs +79 -32
  48. package/dist/core/unauth_client.cjs.map +1 -1
  49. package/dist/core/unauth_client.d.ts +53 -20
  50. package/dist/core/unauth_client.d.ts.map +1 -1
  51. package/dist/core/unauth_client.js +79 -32
  52. package/dist/core/unauth_client.js.map +1 -1
  53. package/dist/core/user_resources.cjs +23 -0
  54. package/dist/core/user_resources.cjs.map +1 -1
  55. package/dist/core/user_resources.d.ts +33 -7
  56. package/dist/core/user_resources.d.ts.map +1 -1
  57. package/dist/core/user_resources.js +23 -0
  58. package/dist/core/user_resources.js.map +1 -1
  59. package/dist/core/wire.d.ts +5 -7
  60. package/dist/core/wire.d.ts.map +1 -1
  61. package/dist/helpers/pl.d.ts +32 -34
  62. package/dist/helpers/pl.d.ts.map +1 -1
  63. package/dist/helpers/poll.d.ts +8 -9
  64. package/dist/helpers/poll.d.ts.map +1 -1
  65. package/dist/helpers/tx_helpers.d.ts +2 -4
  66. package/dist/helpers/tx_helpers.d.ts.map +1 -1
  67. package/dist/index.d.ts +3 -3
  68. package/dist/proto-grpc/github.com/googleapis/googleapis/google/rpc/status.d.ts +2 -4
  69. package/dist/proto-grpc/github.com/googleapis/googleapis/google/rpc/status.d.ts.map +1 -1
  70. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs +310 -15
  71. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.cjs.map +1 -1
  72. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.cjs +19 -3
  73. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.cjs.map +1 -1
  74. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.d.ts +29 -5
  75. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.d.ts.map +1 -1
  76. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.js +19 -3
  77. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.js.map +1 -1
  78. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts +2336 -429
  79. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.d.ts.map +1 -1
  80. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js +309 -16
  81. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.js.map +1 -1
  82. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api_types.d.ts +51 -30
  83. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api_types.d.ts.map +1 -1
  84. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/base_types.d.ts +7 -9
  85. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/base_types.d.ts.map +1 -1
  86. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/resource_types.d.ts +6 -8
  87. package/dist/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/resource_types.d.ts.map +1 -1
  88. package/dist/proto-grpc/google/protobuf/any.d.ts +2 -4
  89. package/dist/proto-grpc/google/protobuf/any.d.ts.map +1 -1
  90. package/dist/proto-grpc/google/protobuf/duration.d.ts +2 -4
  91. package/dist/proto-grpc/google/protobuf/duration.d.ts.map +1 -1
  92. package/dist/proto-grpc/google/protobuf/timestamp.d.ts +2 -4
  93. package/dist/proto-grpc/google/protobuf/timestamp.d.ts.map +1 -1
  94. package/dist/proto-grpc/google/rpc/code.d.ts +1 -2
  95. package/dist/proto-grpc/google/rpc/code.d.ts.map +1 -1
  96. package/dist/proto-rest/index.d.ts +6 -8
  97. package/dist/proto-rest/index.d.ts.map +1 -1
  98. package/dist/proto-rest/plapi.d.ts +248 -123
  99. package/dist/proto-rest/plapi.d.ts.map +1 -1
  100. package/dist/test/tcp-proxy.d.ts +3 -5
  101. package/dist/test/tcp-proxy.d.ts.map +1 -1
  102. package/dist/test/test_config.d.ts +13 -14
  103. package/dist/test/test_config.d.ts.map +1 -1
  104. package/package.json +6 -6
  105. package/src/core/client.ts +9 -0
  106. package/src/core/ll_client.ts +40 -13
  107. package/src/core/transaction.ts +2 -0
  108. package/src/core/unauth_client.test.ts +69 -1
  109. package/src/core/unauth_client.ts +108 -34
  110. package/src/core/unauth_client_branch.test.ts +416 -5
  111. package/src/core/user_resources.ts +39 -0
  112. package/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.ts +34 -3
  113. package/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts +431 -15
  114. package/src/proto-rest/plapi.ts +8 -2
@@ -1,17 +1,39 @@
1
1
  import { test, expect, vi } from "vitest";
2
2
  import { UnauthenticatedPlClient } from "./unauth_client";
3
+ import { LLPlClient } from "./ll_client";
4
+ import { PlatformClient as GrpcPlApiClient } from "../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client";
3
5
  import type { BackendCapability } from "./capabilities";
6
+ import {
7
+ AuthAPI_ListMethods_SSOAuthMethod_FlowType,
8
+ type AuthAPI_ListMethods_Response,
9
+ } from "../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api";
4
10
 
5
11
  type Scheme = "basic" | "token" | "none";
12
+ type MethodInfo = AuthAPI_ListMethods_Response["methods"][number];
6
13
 
7
- function makeStub(opts: { hasAuthV2: boolean; scheme?: Scheme }) {
14
+ function makeStub(opts: { hasAuthV2: boolean; scheme?: Scheme; methods?: MethodInfo[] }) {
8
15
  const scheme = opts.scheme ?? "basic";
9
16
  const methods =
10
- scheme === "basic"
11
- ? [{ id: "basic", method: { oneofKind: "basic", basic: {} } }]
17
+ opts.methods ??
18
+ (scheme === "basic"
19
+ ? [
20
+ {
21
+ id: "basic",
22
+ title: "basic",
23
+ description: "",
24
+ method: { oneofKind: "basic", basic: {} },
25
+ },
26
+ ]
12
27
  : scheme === "token"
13
- ? [{ id: "token", method: { oneofKind: "token", token: {} } }]
14
- : [];
28
+ ? [
29
+ {
30
+ id: "token",
31
+ title: "token",
32
+ description: "",
33
+ method: { oneofKind: "token", token: {} },
34
+ },
35
+ ]
36
+ : []);
15
37
  const ll = {
16
38
  hasCapability: vi.fn(
17
39
  (capability: BackendCapability) => capability === "auth:v2" && opts.hasAuthV2,
@@ -19,6 +41,8 @@ function makeStub(opts: { hasAuthV2: boolean; scheme?: Scheme }) {
19
41
  loginBasic: vi.fn().mockResolvedValue("jwt-from-loginBasic"),
20
42
  loginWithToken: vi.fn().mockResolvedValue("jwt-from-loginWithToken"),
21
43
  getJwtToken: vi.fn().mockResolvedValue("jwt-from-getJwtToken"),
44
+ beginSSOLogin: vi.fn().mockResolvedValue({ nonce: "nonce", expiresAt: new Date() }),
45
+ loginSSO: vi.fn().mockResolvedValue("jwt-from-loginSSO"),
22
46
  authMethodsSync: { methods },
23
47
  conf: { authTTLSeconds: 100 },
24
48
  };
@@ -67,3 +91,390 @@ test("hasCapability proxies to underlying LLPlClient", () => {
67
91
  expect(client.hasCapability("treeFilter:v2")).toBe(false);
68
92
  expect(ll.hasCapability).toHaveBeenCalledWith("auth:v2");
69
93
  });
94
+
95
+ function ssoMethod(overrides: {
96
+ id: string;
97
+ title?: string;
98
+ description: string;
99
+ issuer: string;
100
+ flowType?: AuthAPI_ListMethods_SSOAuthMethod_FlowType;
101
+ clientId?: string;
102
+ scopes?: string;
103
+ resource?: string;
104
+ prompt?: string;
105
+ redirectPorts?: number[];
106
+ subjectTokenSource?: string;
107
+ userIdClaim?: string;
108
+ groupsClaim?: string;
109
+ accessType?: string;
110
+ }): MethodInfo {
111
+ return {
112
+ id: overrides.id,
113
+ title: overrides.title ?? overrides.id,
114
+ description: overrides.description,
115
+ method: {
116
+ oneofKind: "sso",
117
+ sso: {
118
+ issuer: overrides.issuer,
119
+ clientId: overrides.clientId ?? "client-id",
120
+ scopes: overrides.scopes ?? "openid",
121
+ resource: overrides.resource ?? "",
122
+ prompt: overrides.prompt ?? "",
123
+ redirectPorts: overrides.redirectPorts ?? [12345],
124
+ subjectTokenSource: overrides.subjectTokenSource ?? "id_token",
125
+ userIdClaim: overrides.userIdClaim ?? "sub",
126
+ groupsClaim: overrides.groupsClaim ?? "",
127
+ flowType: overrides.flowType ?? AuthAPI_ListMethods_SSOAuthMethod_FlowType.PUBLIC_PKCE,
128
+ accessType: overrides.accessType,
129
+ },
130
+ },
131
+ };
132
+ }
133
+
134
+ function basicMethod(id: string, description = "Basic auth", title = id): MethodInfo {
135
+ return { id, title, description, method: { oneofKind: "basic", basic: {} } };
136
+ }
137
+
138
+ function tokenMethod(id: string, description = "Static token", title = id): MethodInfo {
139
+ return { id, title, description, method: { oneofKind: "token", token: {} } };
140
+ }
141
+
142
+ // --- loginMethods() ---
143
+
144
+ test("loginMethods lists every advertised method, of every kind, in advertised order", () => {
145
+ const methods: MethodInfo[] = [
146
+ ssoMethod({ id: "sso-a", description: "First IdP", issuer: "https://first.example" }),
147
+ ssoMethod({ id: "sso-b", description: "Second IdP", issuer: "https://second.example" }),
148
+ basicMethod("htpasswd-a", "htpasswd"),
149
+ basicMethod("htpasswd-b", "htpasswd"),
150
+ tokenMethod("token-a", "static token"),
151
+ ];
152
+ const { client } = makeStub({ hasAuthV2: true, methods });
153
+
154
+ const listed = client.loginMethods();
155
+
156
+ expect(listed.map((m) => m.kind)).toEqual(["sso", "sso", "basic", "basic", "token"]);
157
+ expect(listed.map((m) => m.id)).toEqual([
158
+ "sso-a",
159
+ "sso-b",
160
+ "htpasswd-a",
161
+ "htpasswd-b",
162
+ "token-a",
163
+ ]);
164
+ expect(listed.map((m) => m.title)).toEqual([
165
+ "sso-a",
166
+ "sso-b",
167
+ "htpasswd-a",
168
+ "htpasswd-b",
169
+ "token-a",
170
+ ]);
171
+ });
172
+
173
+ test("loginMethods falls back to description, then id, when the backend omits title", () => {
174
+ const methods: MethodInfo[] = [
175
+ {
176
+ id: "no-title",
177
+ title: "",
178
+ description: "Untitled backend",
179
+ method: { oneofKind: "basic", basic: {} },
180
+ },
181
+ {
182
+ id: "no-title-no-description",
183
+ title: "",
184
+ description: "",
185
+ method: { oneofKind: "token", token: {} },
186
+ },
187
+ ];
188
+ const { client } = makeStub({ hasAuthV2: true, methods });
189
+
190
+ const listed = client.loginMethods();
191
+
192
+ expect(listed.map((m) => m.title)).toEqual(["Untitled backend", "no-title-no-description"]);
193
+ });
194
+
195
+ test("loginMethods keeps same-kind methods distinct while supportedAuthSchemes still collapses them", () => {
196
+ const methods: MethodInfo[] = [
197
+ basicMethod("htpasswd-a", "htpasswd"),
198
+ basicMethod("htpasswd-b", "htpasswd"),
199
+ tokenMethod("token-a"),
200
+ ];
201
+ const { client } = makeStub({ hasAuthV2: true, methods });
202
+
203
+ const listed = client.loginMethods().filter((m) => m.kind === "basic");
204
+
205
+ expect(listed.map((m) => m.id)).toEqual(["htpasswd-a", "htpasswd-b"]);
206
+ expect(client.supportedAuthSchemes).toEqual({ basic: true, token: true });
207
+ });
208
+
209
+ test("loginMethods maps every field of an SSO projection, and drops an empty accessType", () => {
210
+ const method = ssoMethod({
211
+ id: "sso-1",
212
+ title: "Corporate SSO",
213
+ description: "Corp SSO",
214
+ issuer: "https://idp.example",
215
+ clientId: "the-client-id",
216
+ scopes: "openid profile",
217
+ resource: "https://api.example",
218
+ prompt: "consent",
219
+ redirectPorts: [11000, 11001],
220
+ subjectTokenSource: "access_token",
221
+ userIdClaim: "email",
222
+ groupsClaim: "groups",
223
+ accessType: "",
224
+ });
225
+ const { client } = makeStub({ hasAuthV2: true, methods: [method] });
226
+
227
+ const [entry] = client.loginMethods();
228
+
229
+ expect(entry).toEqual({
230
+ kind: "sso",
231
+ id: "sso-1",
232
+ title: "Corporate SSO",
233
+ description: "Corp SSO",
234
+ issuer: "https://idp.example",
235
+ clientId: "the-client-id",
236
+ scopes: "openid profile",
237
+ resource: "https://api.example",
238
+ prompt: "consent",
239
+ redirectPorts: [11000, 11001],
240
+ subjectTokenSource: "access_token",
241
+ userIdClaim: "email",
242
+ groupsClaim: "groups",
243
+ flowType: "public_pkce",
244
+ accessType: undefined,
245
+ });
246
+ });
247
+
248
+ test("loginMethods drops an advertised method with no usable arm", () => {
249
+ const armless: MethodInfo = {
250
+ id: "legacy",
251
+ title: "legacy",
252
+ description: "",
253
+ method: { oneofKind: undefined },
254
+ };
255
+ const { client } = makeStub({ hasAuthV2: true, methods: [armless, basicMethod("htpasswd-a")] });
256
+
257
+ expect(client.loginMethods().map((m) => m.id)).toEqual(["htpasswd-a"]);
258
+ });
259
+
260
+ test("loginMethods drops an SSO entry whose flow type is unsupported, keeping every other entry", () => {
261
+ const unsupported = ssoMethod({
262
+ id: "sso-legacy",
263
+ description: "Corp SSO",
264
+ issuer: "https://idp.example",
265
+ flowType: 99 as AuthAPI_ListMethods_SSOAuthMethod_FlowType,
266
+ });
267
+ const { client } = makeStub({
268
+ hasAuthV2: true,
269
+ methods: [unsupported, basicMethod("htpasswd-a")],
270
+ });
271
+
272
+ expect(client.loginMethods().map((m) => m.id)).toEqual(["htpasswd-a"]);
273
+ });
274
+
275
+ test("beginSSOLogin forwards the picked id to the low-level client", async () => {
276
+ const { client, ll } = makeStub({ hasAuthV2: true, scheme: "none" });
277
+
278
+ await client.beginSSOLogin("sso-a");
279
+
280
+ expect(ll.beginSSOLogin).toHaveBeenCalledWith("sso-a");
281
+ });
282
+
283
+ test("beginSSOLogin omits the argument when no id is picked", async () => {
284
+ const { client, ll } = makeStub({ hasAuthV2: true, scheme: "none" });
285
+
286
+ await client.beginSSOLogin();
287
+
288
+ expect(ll.beginSSOLogin).toHaveBeenCalledWith();
289
+ });
290
+
291
+ test("loginSSO carries the picked id to the low-level client alongside the token response", async () => {
292
+ const { client, ll } = makeStub({ hasAuthV2: true, scheme: "none" });
293
+ const tokenResponse = new Uint8Array([1, 2, 3]);
294
+
295
+ await client.loginSSO({ tokenResponse, idP: "sso-a" });
296
+
297
+ expect(ll.loginSSO).toHaveBeenCalledWith(tokenResponse, "sso-a");
298
+ });
299
+
300
+ test("loginSSO sends only the token response when no id is picked", async () => {
301
+ const { client, ll } = makeStub({ hasAuthV2: true, scheme: "none" });
302
+ const tokenResponse = new Uint8Array([1, 2, 3]);
303
+
304
+ await client.loginSSO({ tokenResponse });
305
+
306
+ expect(ll.loginSSO).toHaveBeenCalledWith(tokenResponse);
307
+ });
308
+
309
+ test("login forwards the picked id into loginBasic's options bag", async () => {
310
+ const { client, ll } = makeStub({ hasAuthV2: true, scheme: "basic" });
311
+
312
+ await client.login("alice", "pw", "htpasswd-b");
313
+
314
+ expect(ll.loginBasic).toHaveBeenCalledWith("alice", "pw", { idP: "htpasswd-b" });
315
+ });
316
+
317
+ test("login drops the picked id on the token branch", async () => {
318
+ const { client, ll } = makeStub({ hasAuthV2: true, scheme: "token" });
319
+
320
+ const info = await client.login("alice", "opaque-token", "some-id");
321
+
322
+ expect(ll.loginWithToken).toHaveBeenCalledWith("opaque-token");
323
+ expect(info.jwtToken).toBe("jwt-from-loginWithToken");
324
+ });
325
+
326
+ test("login routes to loginWithToken when the picked id names a token method, even though the backend also advertises basic", async () => {
327
+ const { client, ll } = makeStub({
328
+ hasAuthV2: true,
329
+ methods: [
330
+ { id: "ldap-a", title: "ldap-a", description: "", method: { oneofKind: "basic", basic: {} } },
331
+ {
332
+ id: "htpasswd",
333
+ title: "htpasswd",
334
+ description: "",
335
+ method: { oneofKind: "token", token: {} },
336
+ },
337
+ ],
338
+ });
339
+
340
+ const info = await client.login("alice", "opaque-token", "htpasswd");
341
+
342
+ expect(ll.loginWithToken).toHaveBeenCalledWith("opaque-token");
343
+ expect(ll.loginBasic).not.toHaveBeenCalled();
344
+ expect(info.jwtToken).toBe("jwt-from-loginWithToken");
345
+ });
346
+
347
+ test("ssoConfig returns the first sso method and drops the rest", () => {
348
+ const first = ssoMethod({
349
+ id: "sso-first",
350
+ description: "First IdP",
351
+ issuer: "https://first.example",
352
+ });
353
+ const second = ssoMethod({
354
+ id: "sso-second",
355
+ description: "Second IdP",
356
+ issuer: "https://second.example",
357
+ });
358
+ const { client } = makeStub({ hasAuthV2: true, methods: [first, second] });
359
+
360
+ const sso = client.ssoConfig();
361
+
362
+ expect(sso).toMatchObject({
363
+ id: "sso-first",
364
+ description: "First IdP",
365
+ issuer: "https://first.example",
366
+ });
367
+ });
368
+
369
+ test("ssoConfig carries id, title and description onto the returned SSOAuthMethod", () => {
370
+ const method = ssoMethod({
371
+ id: "sso-1",
372
+ title: "Corporate IdP",
373
+ description: "Corp SSO",
374
+ issuer: "https://idp.example",
375
+ });
376
+ const { client } = makeStub({ hasAuthV2: true, methods: [method] });
377
+
378
+ const sso = client.ssoConfig();
379
+
380
+ expect(sso?.id).toBe("sso-1");
381
+ expect(sso?.title).toBe("Corporate IdP");
382
+ expect(sso?.description).toBe("Corp SSO");
383
+ });
384
+
385
+ test("ssoConfig throws when the sso method's flow type is not PUBLIC_PKCE", () => {
386
+ const method = ssoMethod({
387
+ id: "sso-1",
388
+ description: "Corp SSO",
389
+ issuer: "https://idp.example",
390
+ // 99 is not a defined FlowType value; casts an out-of-range value the
391
+ // proto currently has none for, to pin the guard against a future flow.
392
+ flowType: 99 as AuthAPI_ListMethods_SSOAuthMethod_FlowType,
393
+ });
394
+ const { client } = makeStub({ hasAuthV2: true, methods: [method] });
395
+
396
+ expect(() => client.ssoConfig()).toThrow("ssoConfig: unsupported SSO flow type");
397
+ });
398
+
399
+ test("supportedAuthSchemes collapses a basic+token+sso method list to scheme booleans", () => {
400
+ const methods: MethodInfo[] = [
401
+ { id: "basic", title: "basic", description: "", method: { oneofKind: "basic", basic: {} } },
402
+ { id: "token", title: "token", description: "", method: { oneofKind: "token", token: {} } },
403
+ ssoMethod({ id: "sso-1", description: "Corp SSO", issuer: "https://idp.example" }),
404
+ ];
405
+ const { client } = makeStub({ hasAuthV2: true, methods });
406
+
407
+ expect(client.supportedAuthSchemes).toEqual({ basic: true, token: true });
408
+ });
409
+
410
+ // --- LLPlClient.loginBasic carries the id on both transports ---
411
+
412
+ function makeLLStub(cl: { login?: ReturnType<typeof vi.fn>; POST?: ReturnType<typeof vi.fn> }) {
413
+ return Object.assign(Object.create(LLPlClient.prototype) as object, {
414
+ clientProvider: { get: () => cl },
415
+ conf: { authTTLSeconds: 100 },
416
+ }) as LLPlClient;
417
+ }
418
+
419
+ test("loginBasic carries idP in the request body on the gRPC transport", async () => {
420
+ const login = vi.fn(() => ({ response: Promise.resolve({ token: "jwt" }) }));
421
+ const cl = Object.assign(Object.create(GrpcPlApiClient.prototype) as object, { login });
422
+ const ll = makeLLStub(cl);
423
+
424
+ await ll.loginBasic("alice", "pw", { idP: "htpasswd-b" });
425
+
426
+ expect(login).toHaveBeenCalledWith(
427
+ expect.objectContaining({
428
+ credentials: {
429
+ oneofKind: "basic",
430
+ basic: { login: "alice", password: "pw", idp: "htpasswd-b" },
431
+ },
432
+ }),
433
+ expect.anything(),
434
+ );
435
+ });
436
+
437
+ test("loginBasic without idP produces the same gRPC request body as today", async () => {
438
+ const login = vi.fn(() => ({ response: Promise.resolve({ token: "jwt" }) }));
439
+ const cl = Object.assign(Object.create(GrpcPlApiClient.prototype) as object, { login });
440
+ const ll = makeLLStub(cl);
441
+
442
+ await ll.loginBasic("alice", "pw");
443
+
444
+ expect(login).toHaveBeenCalledWith(
445
+ expect.objectContaining({
446
+ credentials: { oneofKind: "basic", basic: { login: "alice", password: "pw" } },
447
+ }),
448
+ expect.anything(),
449
+ );
450
+ });
451
+
452
+ test("loginBasic carries idP in the request body on the REST transport", async () => {
453
+ const POST = vi.fn(() => Promise.resolve({ data: { token: "jwt" } }));
454
+ const ll = makeLLStub({ POST });
455
+
456
+ await ll.loginBasic("alice", "pw", { idP: "htpasswd-b" });
457
+
458
+ expect(POST).toHaveBeenCalledWith(
459
+ "/v1/auth/login",
460
+ expect.objectContaining({
461
+ body: expect.objectContaining({
462
+ basic: { login: "alice", password: "pw", idp: "htpasswd-b" },
463
+ }),
464
+ }),
465
+ );
466
+ });
467
+
468
+ test("loginBasic without idP produces the same REST request body as today", async () => {
469
+ const POST = vi.fn(() => Promise.resolve({ data: { token: "jwt" } }));
470
+ const ll = makeLLStub({ POST });
471
+
472
+ await ll.loginBasic("alice", "pw");
473
+
474
+ expect(POST).toHaveBeenCalledWith(
475
+ "/v1/auth/login",
476
+ expect.objectContaining({
477
+ body: expect.objectContaining({ basic: { login: "alice", password: "pw" } }),
478
+ }),
479
+ );
480
+ });
@@ -36,6 +36,20 @@ export interface UserEntry {
36
36
  readonly login: string;
37
37
  }
38
38
 
39
+ /** What {@link UserResources.deleteUser} removed, so a caller can report it. */
40
+ export interface UserDeletionReport {
41
+ /** Raw id of the root resource the user owned, or undefined when they had none. Unsigned —
42
+ * the resource is gone, so this is for the audit line, not for further calls. */
43
+ readonly userRootId: bigint | undefined;
44
+ /** Whether that root resource was deleted. */
45
+ readonly userRootDeleted: boolean;
46
+ /** Grants revoked, the user-root self-grant included. */
47
+ readonly revokedGrants: number;
48
+ /** Identity-index entries removed — one per value that resolved to the account: its login,
49
+ * its email, and any alternative of either. */
50
+ readonly removedIdentityIndexEntries: number;
51
+ }
52
+
39
53
  /** Information about a single data library (LS storage). */
40
54
  export interface StorageInfo {
41
55
  /** Machine-stable identifier, e.g. "library". Used for filtering and map keys. */
@@ -257,6 +271,31 @@ export class UserResources {
257
271
  return users.map((user) => ({ login: user.login }));
258
272
  }
259
273
 
274
+ /**
275
+ * Deletes a user account: the record, every identity-index entry that resolves to it (its login,
276
+ * its email, and any alternative of either), every grant it holds, and its root resource — and
277
+ * with the root, everything still attached under it.
278
+ *
279
+ * Exists for the duplicate account one person can end up with — an identity that could not be
280
+ * matched across a provider cutover, or one minted before the backend started refusing two
281
+ * records for one identity. The spare record shows up wherever users are listed and its projects
282
+ * keep taking part in deduplication.
283
+ *
284
+ * Destructive and irreversible. Re-attach anything worth keeping to another user's root before
285
+ * calling — `pl-cli admin delete-user` does both steps in order. Requires admin/controller
286
+ * credentials; gRPC-only, like {@link listUsers}.
287
+ */
288
+ async deleteUser(login: string): Promise<UserDeletionReport> {
289
+ const resp = await this.ll.deleteUser(login);
290
+
291
+ return {
292
+ userRootId: resp.userRootId === 0n ? undefined : resp.userRootId,
293
+ userRootDeleted: resp.userRootDeleted,
294
+ revokedGrants: resp.revokedGrants,
295
+ removedIdentityIndexEntries: resp.removedIdentityIndexEntries,
296
+ };
297
+ }
298
+
260
299
  private async getUserRootViaRpc(opts: {
261
300
  login?: string;
262
301
  createIfNotExists: true;
@@ -10,6 +10,8 @@ import type { MaintenanceAPI_Ping_Response } from "./api";
10
10
  import type { MaintenanceAPI_Ping_Request } from "./api";
11
11
  import type { MiscAPI_ListResourceTypes_Response } from "./api";
12
12
  import type { MiscAPI_ListResourceTypes_Request } from "./api";
13
+ import type { AuthAPI_DeleteUser_Response } from "./api";
14
+ import type { AuthAPI_DeleteUser_Request } from "./api";
13
15
  import type { AuthAPI_ListUsers_Response } from "./api";
14
16
  import type { AuthAPI_ListUsers_Request } from "./api";
15
17
  import type { AuthAPI_ListUserResources_Response } from "./api";
@@ -290,6 +292,19 @@ export interface IPlatformClient {
290
292
  * @generated from protobuf rpc: ListUsers
291
293
  */
292
294
  listUsers(input: AuthAPI_ListUsers_Request, options?: RpcOptions): UnaryCall<AuthAPI_ListUsers_Request, AuthAPI_ListUsers_Response>;
295
+ /**
296
+ * DeleteUser removes a user account from the instance: the user record, every login-index
297
+ * entry that resolves to it, every grant it holds, and its root resource with everything
298
+ * still attached under it. Admin-only and irreversible - re-attach anything worth keeping
299
+ * to another user's root before calling (`pl-cli admin delete-user` does both steps).
300
+ *
301
+ * Exists because multi-provider auth can mint a duplicate account for one person: the waste
302
+ * record shows up in the sharing UI and its projects keep taking part in deduplication, and
303
+ * until now nothing could remove it.
304
+ *
305
+ * @generated from protobuf rpc: DeleteUser
306
+ */
307
+ deleteUser(input: AuthAPI_DeleteUser_Request, options?: RpcOptions): UnaryCall<AuthAPI_DeleteUser_Request, AuthAPI_DeleteUser_Response>;
293
308
  /**
294
309
  *
295
310
  * Other stuff
@@ -626,6 +641,22 @@ export class PlatformClient implements IPlatformClient, ServiceInfo {
626
641
  const method = this.methods[34], opt = this._transport.mergeOptions(options);
627
642
  return stackIntercept<AuthAPI_ListUsers_Request, AuthAPI_ListUsers_Response>("unary", this._transport, method, opt, input);
628
643
  }
644
+ /**
645
+ * DeleteUser removes a user account from the instance: the user record, every login-index
646
+ * entry that resolves to it, every grant it holds, and its root resource with everything
647
+ * still attached under it. Admin-only and irreversible - re-attach anything worth keeping
648
+ * to another user's root before calling (`pl-cli admin delete-user` does both steps).
649
+ *
650
+ * Exists because multi-provider auth can mint a duplicate account for one person: the waste
651
+ * record shows up in the sharing UI and its projects keep taking part in deduplication, and
652
+ * until now nothing could remove it.
653
+ *
654
+ * @generated from protobuf rpc: DeleteUser
655
+ */
656
+ deleteUser(input: AuthAPI_DeleteUser_Request, options?: RpcOptions): UnaryCall<AuthAPI_DeleteUser_Request, AuthAPI_DeleteUser_Response> {
657
+ const method = this.methods[35], opt = this._transport.mergeOptions(options);
658
+ return stackIntercept<AuthAPI_DeleteUser_Request, AuthAPI_DeleteUser_Response>("unary", this._transport, method, opt, input);
659
+ }
629
660
  /**
630
661
  *
631
662
  * Other stuff
@@ -634,7 +665,7 @@ export class PlatformClient implements IPlatformClient, ServiceInfo {
634
665
  * @generated from protobuf rpc: ListResourceTypes
635
666
  */
636
667
  listResourceTypes(input: MiscAPI_ListResourceTypes_Request, options?: RpcOptions): UnaryCall<MiscAPI_ListResourceTypes_Request, MiscAPI_ListResourceTypes_Response> {
637
- const method = this.methods[35], opt = this._transport.mergeOptions(options);
668
+ const method = this.methods[36], opt = this._transport.mergeOptions(options);
638
669
  return stackIntercept<MiscAPI_ListResourceTypes_Request, MiscAPI_ListResourceTypes_Response>("unary", this._transport, method, opt, input);
639
670
  }
640
671
  /**
@@ -645,14 +676,14 @@ export class PlatformClient implements IPlatformClient, ServiceInfo {
645
676
  * @generated from protobuf rpc: Ping
646
677
  */
647
678
  ping(input: MaintenanceAPI_Ping_Request, options?: RpcOptions): UnaryCall<MaintenanceAPI_Ping_Request, MaintenanceAPI_Ping_Response> {
648
- const method = this.methods[36], opt = this._transport.mergeOptions(options);
679
+ const method = this.methods[37], opt = this._transport.mergeOptions(options);
649
680
  return stackIntercept<MaintenanceAPI_Ping_Request, MaintenanceAPI_Ping_Response>("unary", this._transport, method, opt, input);
650
681
  }
651
682
  /**
652
683
  * @generated from protobuf rpc: License
653
684
  */
654
685
  license(input: MaintenanceAPI_License_Request, options?: RpcOptions): UnaryCall<MaintenanceAPI_License_Request, MaintenanceAPI_License_Response> {
655
- const method = this.methods[37], opt = this._transport.mergeOptions(options);
686
+ const method = this.methods[38], opt = this._transport.mergeOptions(options);
656
687
  return stackIntercept<MaintenanceAPI_License_Request, MaintenanceAPI_License_Response>("unary", this._transport, method, opt, input);
657
688
  }
658
689
  }