@udibo/oauth2 0.6.0 → 0.8.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/esm/_dnt.polyfills.d.ts +14 -0
- package/esm/_dnt.polyfills.js +11 -0
- package/esm/adapters/hono/bff/bff.d.ts +66 -3
- package/esm/adapters/hono/bff/bff.js +95 -12
- package/esm/client/_http.d.ts +5 -2
- package/esm/client/_http.js +6 -3
- package/esm/client/check.js +11 -32
- package/esm/client/direct-client.d.ts +41 -2
- package/esm/client/direct-client.js +41 -2
- package/esm/client/storage.d.ts +8 -1
- package/esm/server/authorization-server.d.ts +27 -2
- package/esm/server/authorization-server.js +29 -5
- package/esm/testing/contract/auth-request-storage.d.ts +26 -0
- package/esm/testing/contract/auth-request-storage.js +44 -7
- package/esm/testing/contract/mod.d.ts +1 -1
- package/esm/testing/contract/tenant.d.ts +49 -9
- package/esm/testing/contract/tenant.js +631 -5
- package/esm/testing/mod.d.ts +6 -5
- package/esm/testing/mod.js +5 -4
- package/esm/testing/tenant.d.ts +88 -16
- package/esm/testing/tenant.js +971 -57
- package/esm/utils/_body.d.ts +29 -0
- package/esm/utils/_body.js +66 -0
- package/package.json +1 -1
|
@@ -248,6 +248,26 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
|
|
|
248
248
|
* bundle.
|
|
249
249
|
*/
|
|
250
250
|
isPublicSuffix?: IsPublicSuffix;
|
|
251
|
+
/**
|
|
252
|
+
* The largest request body, in bytes, the server reads at the token,
|
|
253
|
+
* revocation, introspection, device authorization and end-session
|
|
254
|
+
* endpoints. Each reads its form body before it has authenticated the
|
|
255
|
+
* caller, so the cap bounds what an anonymous request can make the server
|
|
256
|
+
* buffer. Bytes are counted as they arrive, so a chunked body or a
|
|
257
|
+
* `Content-Length` that understates the body cannot get past it. A larger
|
|
258
|
+
* body is refused with `413` and `invalid_request` before the request's
|
|
259
|
+
* context is resolved or any client is authenticated.
|
|
260
|
+
*
|
|
261
|
+
* The default fits every body these endpoints legitimately receive: the
|
|
262
|
+
* largest field is a JWT (`id_token_hint`, or a token presented for
|
|
263
|
+
* introspection or revocation), a few KiB even with a large claim set.
|
|
264
|
+
* Raise it only if your clients send larger ones.
|
|
265
|
+
*
|
|
266
|
+
* @default 65536
|
|
267
|
+
* @throws {RangeError} From the constructor when it is not a positive
|
|
268
|
+
* integer.
|
|
269
|
+
*/
|
|
270
|
+
maxBodyBytes?: number;
|
|
251
271
|
}
|
|
252
272
|
/**
|
|
253
273
|
* Function to authenticate the user during authorization.
|
|
@@ -407,6 +427,7 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
407
427
|
*
|
|
408
428
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
409
429
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
430
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
410
431
|
*/
|
|
411
432
|
constructor(options: AuthorizationServerOptions<Client, User, S>);
|
|
412
433
|
/**
|
|
@@ -453,7 +474,9 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
453
474
|
* `application/x-www-form-urlencoded`, or no `grant_type` — is a 400
|
|
454
475
|
* `invalid_request`. A `grant_type` with no registered grant is
|
|
455
476
|
* `unsupported_grant_type`, checked before client authentication; a client
|
|
456
|
-
* not registered for the grant is `unauthorized_client`.
|
|
477
|
+
* not registered for the grant is `unauthorized_client`. A body over
|
|
478
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
479
|
+
* `invalid_request`, refused before the request context is resolved.
|
|
457
480
|
*/
|
|
458
481
|
handleTokenRequest(request: Request): Promise<Response>;
|
|
459
482
|
/**
|
|
@@ -522,7 +545,9 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
522
545
|
* when sent, names the client ahead of the hint's audience, and when both
|
|
523
546
|
* are sent `client_id` must be one of the hint's audiences — a mismatch is
|
|
524
547
|
* a 400 `invalid_request` (OIDC RP-Initiated Logout 1.0 §2). `state` rides
|
|
525
|
-
* along only to a URI that passed authorization.
|
|
548
|
+
* along only to a URI that passed authorization. A POST body over
|
|
549
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
550
|
+
* `invalid_request`, refused before `endSession` runs.
|
|
526
551
|
*
|
|
527
552
|
* Wire the app's session teardown through the `endSession` **constructor
|
|
528
553
|
* option**; this method answers 404 without it, and the discovery document
|
|
@@ -29,6 +29,7 @@ import { DeviceAuthorizationGrant } from "./grants/device-authorization.js";
|
|
|
29
29
|
import { ResourceServer, } from "./resource-server.js";
|
|
30
30
|
import { defaultSubjectOf, signJwt, verifyJwt, } from "./signing-keys.js";
|
|
31
31
|
import { validateCodeChallenge } from "../utils/pkce.js";
|
|
32
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../utils/_body.js";
|
|
32
33
|
const ENDPOINTS = {
|
|
33
34
|
authorization: {
|
|
34
35
|
option: "authorizationEndpoint",
|
|
@@ -186,6 +187,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
186
187
|
signingKeys;
|
|
187
188
|
#endSession;
|
|
188
189
|
#isPublicSuffix;
|
|
190
|
+
#maxBodyBytes;
|
|
189
191
|
#subjectOf;
|
|
190
192
|
#userClaims;
|
|
191
193
|
#introspectionClaims;
|
|
@@ -197,6 +199,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
197
199
|
*
|
|
198
200
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
199
201
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
202
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
200
203
|
*/
|
|
201
204
|
constructor(options) {
|
|
202
205
|
super({
|
|
@@ -227,6 +230,8 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
227
230
|
"plain OAuth2.");
|
|
228
231
|
}
|
|
229
232
|
this.#isPublicSuffix = options.isPublicSuffix;
|
|
233
|
+
this.#maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
234
|
+
assertMaxBodyBytes(this.#maxBodyBytes, "maxBodyBytes");
|
|
230
235
|
this.#subjectOf = options.subjectOf ?? defaultSubjectOf;
|
|
231
236
|
this.#userClaims = options.userClaims;
|
|
232
237
|
this.#introspectionClaims = options.introspectionClaims;
|
|
@@ -343,6 +348,18 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
343
348
|
}
|
|
344
349
|
return grant;
|
|
345
350
|
}
|
|
351
|
+
async #readBody(request, options = {}) {
|
|
352
|
+
const bytes = await readBoundedBody(request, this.#maxBodyBytes, options)
|
|
353
|
+
.catch((cause) => {
|
|
354
|
+
throw new InvalidRequestError("request body could not be read", {
|
|
355
|
+
cause,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
if (!bytes) {
|
|
359
|
+
throw new InvalidRequestError(413, `request body exceeds ${this.#maxBodyBytes} bytes`);
|
|
360
|
+
}
|
|
361
|
+
return bytes;
|
|
362
|
+
}
|
|
346
363
|
async #beginClientAuthenticatedRequest(request, options = {}) {
|
|
347
364
|
if (request.method !== "POST") {
|
|
348
365
|
throw new InvalidRequestError("method must be POST");
|
|
@@ -351,10 +368,13 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
351
368
|
if (!contentType?.includes("application/x-www-form-urlencoded")) {
|
|
352
369
|
throw new InvalidRequestError("content-type header must be application/x-www-form-urlencoded");
|
|
353
370
|
}
|
|
354
|
-
const
|
|
355
|
-
const body = await
|
|
371
|
+
const bytes = await this.#readBody(request, { keepReadable: true });
|
|
372
|
+
const body = await new Response(bytes, {
|
|
373
|
+
headers: { "content-type": contentType },
|
|
374
|
+
}).formData().catch(() => {
|
|
356
375
|
throw new InvalidRequestError("body must be application/x-www-form-urlencoded");
|
|
357
376
|
});
|
|
377
|
+
const context = await this.authorizationContext(request);
|
|
358
378
|
const grant = options.grantFromBody?.(body);
|
|
359
379
|
const client = grant
|
|
360
380
|
? await grant.getAuthenticatedClient(request, body)
|
|
@@ -372,7 +392,9 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
372
392
|
* `application/x-www-form-urlencoded`, or no `grant_type` — is a 400
|
|
373
393
|
* `invalid_request`. A `grant_type` with no registered grant is
|
|
374
394
|
* `unsupported_grant_type`, checked before client authentication; a client
|
|
375
|
-
* not registered for the grant is `unauthorized_client`.
|
|
395
|
+
* not registered for the grant is `unauthorized_client`. A body over
|
|
396
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
397
|
+
* `invalid_request`, refused before the request context is resolved.
|
|
376
398
|
*/
|
|
377
399
|
async handleTokenRequest(request) {
|
|
378
400
|
try {
|
|
@@ -523,7 +545,9 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
523
545
|
* when sent, names the client ahead of the hint's audience, and when both
|
|
524
546
|
* are sent `client_id` must be one of the hint's audiences — a mismatch is
|
|
525
547
|
* a 400 `invalid_request` (OIDC RP-Initiated Logout 1.0 §2). `state` rides
|
|
526
|
-
* along only to a URI that passed authorization.
|
|
548
|
+
* along only to a URI that passed authorization. A POST body over
|
|
549
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
550
|
+
* `invalid_request`, refused before `endSession` runs.
|
|
527
551
|
*
|
|
528
552
|
* Wire the app's session teardown through the `endSession` **constructor
|
|
529
553
|
* option**; this method answers 404 without it, and the discovery document
|
|
@@ -572,7 +596,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
572
596
|
async #endSessionParameters(request) {
|
|
573
597
|
const url = new URL(request.url);
|
|
574
598
|
const params = request.method === "POST"
|
|
575
|
-
? new URLSearchParams(await request
|
|
599
|
+
? new URLSearchParams(new TextDecoder().decode(await this.#readBody(request)))
|
|
576
600
|
: url.searchParams;
|
|
577
601
|
const idTokenHint = params.get("id_token_hint");
|
|
578
602
|
const hint = idTokenHint ? await this.#readIdTokenHint(idTokenHint) : null;
|
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
* });
|
|
23
23
|
* ```
|
|
24
24
|
*
|
|
25
|
+
* @example Verify a store shared between users, whose `clear()` removes only expired records
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
|
|
28
|
+
* import type { AuthRequestStorage } from "@udibo/oauth2/client";
|
|
29
|
+
*
|
|
30
|
+
* declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
|
|
31
|
+
*
|
|
32
|
+
* runAuthRequestStorageContractTests({
|
|
33
|
+
* describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
|
|
34
|
+
* makeStore: freshSharedAuthRequestStorage,
|
|
35
|
+
* clear: "scoped",
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
25
39
|
* @module
|
|
26
40
|
*/
|
|
27
41
|
import type { AuthRequestStorage } from "../../client/storage.js";
|
|
@@ -41,6 +55,18 @@ export interface AuthRequestStorageContractOptions {
|
|
|
41
55
|
* that meant to implement it and does not fails loudly.
|
|
42
56
|
*/
|
|
43
57
|
take?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* What the store's {@link AuthRequestStorage.clear} removes. Defaults to
|
|
60
|
+
* `"all"`: every record, as a store private to one browser or session
|
|
61
|
+
* does. Pass `"scoped"` for a store shared between users whose `clear()`
|
|
62
|
+
* deliberately removes less — only expired records, say — because a
|
|
63
|
+
* `DirectClient` clears it on every sign-out, and removing everything would
|
|
64
|
+
* cancel other users' sign-ins still in progress. The suite then drops the
|
|
65
|
+
* clear-everything case and instead requires that `clear()` leaves every
|
|
66
|
+
* record still inside its lifetime readable and redeemable, and removes a
|
|
67
|
+
* record created at the epoch (`createdAt: 0`), which is past any lifetime.
|
|
68
|
+
*/
|
|
69
|
+
clear?: "all" | "scoped";
|
|
44
70
|
/**
|
|
45
71
|
* Overrides the name passed to the outer `describe` block. Defaults to
|
|
46
72
|
* `"AuthRequestStorage contract"`.
|
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
* });
|
|
23
23
|
* ```
|
|
24
24
|
*
|
|
25
|
+
* @example Verify a store shared between users, whose `clear()` removes only expired records
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
|
|
28
|
+
* import type { AuthRequestStorage } from "@udibo/oauth2/client";
|
|
29
|
+
*
|
|
30
|
+
* declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
|
|
31
|
+
*
|
|
32
|
+
* runAuthRequestStorageContractTests({
|
|
33
|
+
* describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
|
|
34
|
+
* makeStore: freshSharedAuthRequestStorage,
|
|
35
|
+
* clear: "scoped",
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
25
39
|
* @module
|
|
26
40
|
*/
|
|
27
41
|
import { assert, assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
|
|
@@ -47,6 +61,7 @@ function record(overrides = {}) {
|
|
|
47
61
|
*/
|
|
48
62
|
export function runAuthRequestStorageContractTests(options) {
|
|
49
63
|
const expectTake = options.take ?? true;
|
|
64
|
+
const clearScope = options.clear ?? "all";
|
|
50
65
|
describe(options.describeName ?? "AuthRequestStorage contract", () => {
|
|
51
66
|
let store;
|
|
52
67
|
beforeEach(async () => {
|
|
@@ -74,13 +89,35 @@ export function runAuthRequestStorageContractTests(options) {
|
|
|
74
89
|
assertStrictEquals(await store.get("state-1"), null);
|
|
75
90
|
assert(await store.get("state-2") !== null, "delete must remove only the named state");
|
|
76
91
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
if (clearScope === "all") {
|
|
93
|
+
it("clears every record", async () => {
|
|
94
|
+
await store.set("state-1", record());
|
|
95
|
+
await store.set("state-2", record());
|
|
96
|
+
await store.clear();
|
|
97
|
+
assertStrictEquals(await store.get("state-1"), null);
|
|
98
|
+
assertStrictEquals(await store.get("state-2"), null);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
it('leaves every in-progress record in place (clear: "scoped")', async () => {
|
|
103
|
+
const first = record({ codeVerifier: "verifier-1" });
|
|
104
|
+
const second = record({ codeVerifier: "verifier-2" });
|
|
105
|
+
await store.set("state-1", first);
|
|
106
|
+
await store.set("state-2", second);
|
|
107
|
+
await store.clear();
|
|
108
|
+
const message = "clear() on a shared store must not cancel another user's sign-in " +
|
|
109
|
+
"that is still in progress";
|
|
110
|
+
assertEquals(await store.get("state-2"), second, message);
|
|
111
|
+
assertEquals(expectTake
|
|
112
|
+
? await store.take("state-1")
|
|
113
|
+
: await store.get("state-1"), first, message);
|
|
114
|
+
});
|
|
115
|
+
it('removes a record past any lifetime (clear: "scoped")', async () => {
|
|
116
|
+
await store.set("expired", record({ createdAt: 0 }));
|
|
117
|
+
await store.clear();
|
|
118
|
+
assertStrictEquals(await store.get("expired"), null, "a scoped clear() must still remove expired records");
|
|
119
|
+
});
|
|
120
|
+
}
|
|
84
121
|
});
|
|
85
122
|
if (expectTake) {
|
|
86
123
|
describe("take", () => {
|
|
@@ -39,4 +39,4 @@ export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-s
|
|
|
39
39
|
export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.js";
|
|
40
40
|
export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
|
|
41
41
|
export { type AuthRequestStorageContractOptions, runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
|
|
42
|
-
export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractTokens, } from "./tenant.js";
|
|
42
|
+
export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractProfile, type TenantContractSignInOptions, type TenantContractTokens, } from "./tenant.js";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contract test suite for a Udibo Identity tenant
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* answer
|
|
2
|
+
* Contract test suite for what a Udibo Identity tenant answers an application
|
|
3
|
+
* about its signed-in people: what introspection reports, what
|
|
4
|
+
* `GET /api/memberships` lists, what `POST /api/check` and
|
|
5
|
+
* `POST /api/check/batch` answer, the organization API under
|
|
6
|
+
* `/api/organizations`, and the account API under `/api/account`.
|
|
6
7
|
*
|
|
7
8
|
* It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
|
|
8
9
|
* real identity service in Udibo's own repository, so the fake an app tests
|
|
@@ -35,8 +36,9 @@ export interface TenantContractFixture {
|
|
|
35
36
|
/** The tenant's origin; its RFC 8414 metadata is served beneath it. */
|
|
36
37
|
issuer: string;
|
|
37
38
|
/**
|
|
38
|
-
* A confidential client the suite signs in with, introspects
|
|
39
|
-
* refreshes with. It must be allowed the
|
|
39
|
+
* A confidential first-party client the suite signs in with, introspects
|
|
40
|
+
* with, refreshes with and revokes with. It must be allowed the
|
|
41
|
+
* `refresh_token` grant.
|
|
40
42
|
*/
|
|
41
43
|
client: {
|
|
42
44
|
id: string;
|
|
@@ -47,8 +49,19 @@ export interface TenantContractFixture {
|
|
|
47
49
|
* one when the tenant's host resolves only through a proxy.
|
|
48
50
|
*/
|
|
49
51
|
fetch?: typeof fetch;
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Adds a person holding `permissions` tenant-wide, and returns their id. The
|
|
54
|
+
* organization and account checks pass a `profile`; the others do not.
|
|
55
|
+
*/
|
|
56
|
+
addUser(permissions: string[], profile?: TenantContractProfile): Promise<string>;
|
|
57
|
+
/**
|
|
58
|
+
* Links an external sign-in account to a person and returns the linked
|
|
59
|
+
* account's id.
|
|
60
|
+
*/
|
|
61
|
+
linkAccount(userId: string, account: {
|
|
62
|
+
provider: string;
|
|
63
|
+
email: string | null;
|
|
64
|
+
}): Promise<string>;
|
|
52
65
|
/** Adds an organization and returns its id. */
|
|
53
66
|
addOrganization(): Promise<string>;
|
|
54
67
|
/**
|
|
@@ -77,11 +90,38 @@ export interface TenantContractFixture {
|
|
|
77
90
|
* Signs the person in through the authorization-code flow and returns the
|
|
78
91
|
* tokens it issued. With an organization named, the credential is issued in
|
|
79
92
|
* it; with none, the sign-in picks nothing, leaving the tenant to decide.
|
|
93
|
+
* Each sign-in comes from a new browser and so starts a login session of its
|
|
94
|
+
* own, unless `options.sameBrowser` says otherwise.
|
|
80
95
|
*/
|
|
81
|
-
signIn(userId: string, organizationId?: string): Promise<TenantContractTokens>;
|
|
96
|
+
signIn(userId: string, organizationId?: string, options?: TenantContractSignInOptions): Promise<TenantContractTokens>;
|
|
82
97
|
/** Releases anything `setup` allocated. */
|
|
83
98
|
cleanup?(): Promise<void> | void;
|
|
84
99
|
}
|
|
100
|
+
/** How `TenantContractFixture.signIn` signs a person in. */
|
|
101
|
+
export interface TenantContractSignInOptions {
|
|
102
|
+
/**
|
|
103
|
+
* Sign in from the browser this person's previous sign-in used, so the
|
|
104
|
+
* tenant continues that login session instead of starting another. The
|
|
105
|
+
* suite asks for it only straight after that previous sign-in.
|
|
106
|
+
*/
|
|
107
|
+
sameBrowser?: boolean;
|
|
108
|
+
}
|
|
109
|
+
/** Who a person added with `TenantContractFixture.addUser` is. */
|
|
110
|
+
export interface TenantContractProfile {
|
|
111
|
+
/**
|
|
112
|
+
* The address the person holds. When it is omitted, the fixture may give them
|
|
113
|
+
* any address or none.
|
|
114
|
+
*/
|
|
115
|
+
email?: string;
|
|
116
|
+
/** Whether that address is verified. Defaults to `true`. */
|
|
117
|
+
emailVerified?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Whether the person has a password. Defaults to `true`. With `false`, the
|
|
120
|
+
* fixture must still be able to sign them in, the way a tenant signs such a
|
|
121
|
+
* person in through a linked account.
|
|
122
|
+
*/
|
|
123
|
+
password?: boolean;
|
|
124
|
+
}
|
|
85
125
|
/** Options for {@link runTenantContractTests}. */
|
|
86
126
|
export interface TenantContractOptions {
|
|
87
127
|
/** Overrides the top-level `describe` name. */
|