@stapel/workspaces-react 0.7.0 → 0.9.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/CHANGELOG.md +42 -0
- package/dist/api/generated/schema.d.ts +497 -5
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +91 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/workspacesApi.d.ts +35 -1
- package/dist/api/workspacesApi.d.ts.map +1 -1
- package/dist/api/workspacesApi.js +20 -0
- package/dist/api/workspacesApi.js.map +1 -1
- package/dist/default/MembersManager.js +2 -2
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/model/mutations.d.ts +86 -2
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +152 -1
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queries.d.ts +90 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +115 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/model/queryKeys.d.ts +11 -1
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +21 -1
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/stepUp.d.ts +25 -0
- package/dist/model/stepUp.d.ts.map +1 -0
- package/dist/model/stepUp.js +79 -0
- package/dist/model/stepUp.js.map +1 -0
- package/llms.txt +15 -3
- package/manifest.json +147 -2
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +497 -5
- package/src/api/types.ts +101 -2
- package/src/api/workspacesApi.ts +93 -0
- package/src/default/MembersManager.tsx +2 -2
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- package/src/index.ts +30 -2
- package/src/model/mutations.ts +193 -1
- package/src/model/queries.ts +162 -2
- package/src/model/queryKeys.ts +38 -2
- package/src/model/stepUp.ts +96 -0
package/src/model/queries.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { useQuery } from "@tanstack/react-query";
|
|
2
|
-
import type {
|
|
1
|
+
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
InfiniteData,
|
|
4
|
+
UseInfiniteQueryResult,
|
|
5
|
+
UseQueryResult,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
3
7
|
import { useActiveSessionReady } from "@stapel/core";
|
|
4
8
|
import type { StapelApiError } from "@stapel/core";
|
|
5
9
|
import type {
|
|
10
|
+
InvitationPage,
|
|
6
11
|
InvitationPreview,
|
|
12
|
+
InstanceShape,
|
|
13
|
+
InvitationsParams,
|
|
7
14
|
MemberPage,
|
|
8
15
|
MembersParams,
|
|
9
16
|
RoleInfo,
|
|
@@ -13,6 +20,8 @@ import type {
|
|
|
13
20
|
import { useWorkspacesApi } from "./context.js";
|
|
14
21
|
import { workspacesQueryKeys } from "./queryKeys.js";
|
|
15
22
|
import { hasCapability } from "./capabilities.js";
|
|
23
|
+
import { SENSITIVE_SCOPE, capabilityLevel } from "./stepUp.js";
|
|
24
|
+
import type { CapabilityLevel } from "./stepUp.js";
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* Read hooks over the workspaces API. Staleness follows core's query defaults;
|
|
@@ -81,6 +90,70 @@ export function useMembers(
|
|
|
81
90
|
});
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/**
|
|
94
|
+
* One page of a workspace's invitations (GET /{id}/invitations, #109) — the
|
|
95
|
+
* admin's "who has not accepted yet" table. Disabled until a `workspaceId` is
|
|
96
|
+
* given (and the session is ready — see {@link useWorkspace}).
|
|
97
|
+
*
|
|
98
|
+
* ANCHOR pagination, exactly like {@link useMembers}: pass a page's
|
|
99
|
+
* `next_anchor` as `anchor` to walk forward. There is no page NUMBER — an
|
|
100
|
+
* offset would skew the moment an invitation is revoked or accepted while the
|
|
101
|
+
* admin is reading, which on this screen is the normal case, not the edge one.
|
|
102
|
+
* For scroll-to-load-more use {@link useInfiniteInvitations}.
|
|
103
|
+
*
|
|
104
|
+
* `status` defaults to the backend's `pending` (live, seat-reserving
|
|
105
|
+
* invitations); pass `never_accepted` to include declined/revoked/expired, or
|
|
106
|
+
* `all` for the full history.
|
|
107
|
+
*/
|
|
108
|
+
export function useInvitations(
|
|
109
|
+
workspaceId: string | null,
|
|
110
|
+
params?: InvitationsParams
|
|
111
|
+
): UseQueryResult<InvitationPage, StapelApiError> {
|
|
112
|
+
const api = useWorkspacesApi();
|
|
113
|
+
const sessionReady = useActiveSessionReady();
|
|
114
|
+
const p = params ?? {};
|
|
115
|
+
return useQuery({
|
|
116
|
+
queryKey: workspacesQueryKeys.invitationsPage(workspaceId ?? "", p),
|
|
117
|
+
queryFn: () => api.listInvitations(workspaceId as string, p),
|
|
118
|
+
enabled: sessionReady && workspaceId !== null && workspaceId !== "",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A workspace's invitations as an infinite (load-more) list. Follows the
|
|
124
|
+
* backend's ANCHOR pagination and nothing else: the page param IS the
|
|
125
|
+
* previous page's `next_anchor`, and the walk stops when `has_next` goes
|
|
126
|
+
* false. `data.pages.flatMap(p => p.items)` is the flat row list.
|
|
127
|
+
*
|
|
128
|
+
* `filters` (`status` / `search` / `limit`) are part of the query key, so
|
|
129
|
+
* flipping the status tab starts a fresh anchor walk rather than appending
|
|
130
|
+
* rows of one filter to the pages of another.
|
|
131
|
+
*/
|
|
132
|
+
export function useInfiniteInvitations(
|
|
133
|
+
workspaceId: string | null,
|
|
134
|
+
filters?: Omit<InvitationsParams, "anchor" | "direction">
|
|
135
|
+
): UseInfiniteQueryResult<
|
|
136
|
+
InfiniteData<InvitationPage, string | undefined>,
|
|
137
|
+
StapelApiError
|
|
138
|
+
> {
|
|
139
|
+
const api = useWorkspacesApi();
|
|
140
|
+
const sessionReady = useActiveSessionReady();
|
|
141
|
+
const f = filters ?? {};
|
|
142
|
+
return useInfiniteQuery({
|
|
143
|
+
queryKey: workspacesQueryKeys.invitationsInfinite(workspaceId ?? "", f),
|
|
144
|
+
queryFn: ({ pageParam }) =>
|
|
145
|
+
api.listInvitations(workspaceId as string, {
|
|
146
|
+
...f,
|
|
147
|
+
direction: "next",
|
|
148
|
+
...(pageParam !== undefined ? { anchor: pageParam } : {}),
|
|
149
|
+
}),
|
|
150
|
+
initialPageParam: undefined as string | undefined,
|
|
151
|
+
getNextPageParam: (last) =>
|
|
152
|
+
last.has_next ? (last.next_anchor ?? undefined) : undefined,
|
|
153
|
+
enabled: sessionReady && workspaceId !== null && workspaceId !== "",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
84
157
|
/**
|
|
85
158
|
* The effective role registry (GET /roles, org-program §A2): builtin four +
|
|
86
159
|
* the deployment's `STAPEL_WORKSPACES["ROLES"]` overlay, capability strings
|
|
@@ -101,6 +174,35 @@ export function useRoles(): UseQueryResult<
|
|
|
101
174
|
});
|
|
102
175
|
}
|
|
103
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Deployment shape (GET /instance, unauthenticated).
|
|
179
|
+
*
|
|
180
|
+
* Answers the question a screen must ask BEFORE deciding what to show
|
|
181
|
+
* someone with no workspace: is this a closed deployment (`landing:
|
|
182
|
+
* "none"` — no workspace exists and none can be created) or a public cloud
|
|
183
|
+
* (`"personal"` — one exists and the screen can lead there)?
|
|
184
|
+
*
|
|
185
|
+
* Why a hook instead of a profile field: the reader is exactly the person
|
|
186
|
+
* who has no access — kicked out of their workspace, or having left it. So
|
|
187
|
+
* the request deliberately does NOT wait for a session, unlike {@link
|
|
188
|
+
* useRoles}: waiting would mean never answering the one person it exists for.
|
|
189
|
+
*
|
|
190
|
+
* The data is static per deployment — it only changes alongside the
|
|
191
|
+
* deployment itself, so the cache lives until page reload and is never
|
|
192
|
+
* refetched.
|
|
193
|
+
*/
|
|
194
|
+
export function useInstanceShape(): UseQueryResult<
|
|
195
|
+
InstanceShape,
|
|
196
|
+
StapelApiError
|
|
197
|
+
> {
|
|
198
|
+
const api = useWorkspacesApi();
|
|
199
|
+
return useQuery({
|
|
200
|
+
queryKey: workspacesQueryKeys.instance(),
|
|
201
|
+
queryFn: () => api.getInstanceShape(),
|
|
202
|
+
staleTime: Infinity,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
104
206
|
/**
|
|
105
207
|
* Public invitation preview (GET /invitations/{token}, AllowAny — org-program
|
|
106
208
|
* §B2): what the `/invite/{token}` page renders BEFORE any auth decision.
|
|
@@ -150,3 +252,61 @@ export function useCapabilities(workspaceId: string | null): CapabilitiesResult
|
|
|
150
252
|
error: query.error ?? null,
|
|
151
253
|
};
|
|
152
254
|
}
|
|
255
|
+
|
|
256
|
+
/** What {@link useCapabilityGate} answers about ONE capability, BEFORE the
|
|
257
|
+
* operation is attempted. */
|
|
258
|
+
export interface CapabilityGate {
|
|
259
|
+
/** The capability asked about, verbatim. */
|
|
260
|
+
readonly capability: string;
|
|
261
|
+
/** Does the caller's role carry it? Deny-by-default while loading. */
|
|
262
|
+
readonly allowed: boolean;
|
|
263
|
+
/** Its declared step-up level (ported registry — see `./stepUp.js`). */
|
|
264
|
+
readonly level: CapabilityLevel;
|
|
265
|
+
/** True when `level === "high"`: the backend ALSO demands a fresh
|
|
266
|
+
* verification (scope {@link SENSITIVE_SCOPE}) on top of the capability. */
|
|
267
|
+
readonly requiresStepUp: boolean;
|
|
268
|
+
/** The verification scope of that demand, or `null` at `standard`. */
|
|
269
|
+
readonly stepUpScope: string | null;
|
|
270
|
+
readonly isLoading: boolean;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Ask about one capability BEFORE offering the operation (org-program §A3).
|
|
275
|
+
*
|
|
276
|
+
* Two different answers, both needed in front of the button rather than
|
|
277
|
+
* behind it:
|
|
278
|
+
*
|
|
279
|
+
* * `allowed` — the caller's role carries the mandate. UI convenience only,
|
|
280
|
+
* deny-by-default; the backend re-checks. This is what replaces guessing
|
|
281
|
+
* access from the ROLE NAME: a deployment role like `secretary` can hold
|
|
282
|
+
* `members.invite` and would still fail a `role === "admin"` test forever,
|
|
283
|
+
* no matter what the registry says.
|
|
284
|
+
* * `requiresStepUp` — the operation is declared `high`, so a fresh step-up
|
|
285
|
+
* verification is demanded on top. Knowing this up front is what lets a
|
|
286
|
+
* screen say "you'll be asked to confirm" (or pre-drive enrollment)
|
|
287
|
+
* instead of showing a button that answers 403.
|
|
288
|
+
*
|
|
289
|
+
* Example — the administrative password reset:
|
|
290
|
+
* ```tsx
|
|
291
|
+
* const gate = useCapabilityGate(workspaceId, "members.password.reset");
|
|
292
|
+
* // gate.allowed === false → do not render the button at all
|
|
293
|
+
* // gate.requiresStepUp → render it with the "we will ask you to
|
|
294
|
+
* // confirm" affordance, wired to the app's
|
|
295
|
+
* // verification controller
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
298
|
+
export function useCapabilityGate(
|
|
299
|
+
workspaceId: string | null,
|
|
300
|
+
capability: string
|
|
301
|
+
): CapabilityGate {
|
|
302
|
+
const { can, isLoading } = useCapabilities(workspaceId);
|
|
303
|
+
const level = capabilityLevel(capability);
|
|
304
|
+
return {
|
|
305
|
+
capability,
|
|
306
|
+
allowed: can(capability),
|
|
307
|
+
level,
|
|
308
|
+
requiresStepUp: level === "high",
|
|
309
|
+
stepUpScope: level === "high" ? SENSITIVE_SCOPE : null,
|
|
310
|
+
isLoading,
|
|
311
|
+
};
|
|
312
|
+
}
|
package/src/model/queryKeys.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { MembersParams } from "../api/types.js";
|
|
1
|
+
import type { InvitationsParams, MembersParams } from "../api/types.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Namespaced TanStack Query keys (frontend-standard §2 —
|
|
4
|
+
* Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
|
|
5
5
|
* Everything under the `"workspaces"` root so a host can invalidate the whole
|
|
6
6
|
* module or match a single resource. Persist scope is per-user via core's query
|
|
7
7
|
* runtime (`setPersistUser`). Explicit tuple return types satisfy
|
|
@@ -18,7 +18,23 @@ export const workspacesQueryKeys: {
|
|
|
18
18
|
workspaceId: string,
|
|
19
19
|
params: MembersParams
|
|
20
20
|
): readonly ["workspaces", "members", string, MembersParams];
|
|
21
|
+
invitations(workspaceId: string): readonly ["workspaces", "invitations", string];
|
|
22
|
+
invitationsPage(
|
|
23
|
+
workspaceId: string,
|
|
24
|
+
params: InvitationsParams
|
|
25
|
+
): readonly ["workspaces", "invitations", string, InvitationsParams];
|
|
26
|
+
invitationsInfinite(
|
|
27
|
+
workspaceId: string,
|
|
28
|
+
filters: InvitationsParams
|
|
29
|
+
): readonly [
|
|
30
|
+
"workspaces",
|
|
31
|
+
"invitations",
|
|
32
|
+
string,
|
|
33
|
+
"infinite",
|
|
34
|
+
InvitationsParams,
|
|
35
|
+
];
|
|
21
36
|
roles(): readonly ["workspaces", "roles"];
|
|
37
|
+
instance(): readonly ["workspaces", "instance"];
|
|
22
38
|
invitationPreview(
|
|
23
39
|
token: string
|
|
24
40
|
): readonly ["workspaces", "invitation-preview", string];
|
|
@@ -30,7 +46,27 @@ export const workspacesQueryKeys: {
|
|
|
30
46
|
// invalidating `members(workspaceId)` (mutations.ts) drops every page.
|
|
31
47
|
members: (workspaceId) => [ROOT, "members", workspaceId],
|
|
32
48
|
membersPage: (workspaceId, params) => [ROOT, "members", workspaceId, params],
|
|
49
|
+
// Same prefix trick as `members`: the bare 3-tuple invalidates every page
|
|
50
|
+
// (and the infinite list, which keys on it) after a revoke / resend.
|
|
51
|
+
invitations: (workspaceId) => [ROOT, "invitations", workspaceId],
|
|
52
|
+
invitationsPage: (workspaceId, params) => [
|
|
53
|
+
ROOT,
|
|
54
|
+
"invitations",
|
|
55
|
+
workspaceId,
|
|
56
|
+
params,
|
|
57
|
+
],
|
|
58
|
+
// The infinite list carries its own marker segment: its cached value is an
|
|
59
|
+
// `InfiniteData` envelope, not a page, so it must never collide with a
|
|
60
|
+
// single-page key that happens to hold the same filters.
|
|
61
|
+
invitationsInfinite: (workspaceId, filters) => [
|
|
62
|
+
ROOT,
|
|
63
|
+
"invitations",
|
|
64
|
+
workspaceId,
|
|
65
|
+
"infinite",
|
|
66
|
+
filters,
|
|
67
|
+
],
|
|
33
68
|
roles: () => [ROOT, "roles"],
|
|
69
|
+
instance: () => [ROOT, "instance"],
|
|
34
70
|
// NOTE: the key carries the invite TOKEN (a secret). Core's query persist
|
|
35
71
|
// scope is per-user and the preview response is already public-by-design
|
|
36
72
|
// (masked email only) — but hosts logging query keys should treat this one
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-up ahead of the button, not behind it (org-program §A3).
|
|
3
|
+
*
|
|
4
|
+
* Some workspace operations are declared **high**: the backend gates them
|
|
5
|
+
* with `stapel_core`'s `@requires_verification(scope="sensitive")` on top of
|
|
6
|
+
* the capability check, so an ambient cookie is never enough. A client that
|
|
7
|
+
* only learns this from the 403 shows the admin a button, a spinner and a
|
|
8
|
+
* refusal; this module lets it know BEFORE the click.
|
|
9
|
+
*
|
|
10
|
+
* Two things travel from the backend, and they are not the same thing:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A step-up CHALLENGE** — `verification.challenge_id` is present. This
|
|
13
|
+
* one nobody has to handle here: `@stapel/core`'s client intercepts it,
|
|
14
|
+
* hands it to the app's verification controller (auth-react's factor
|
|
15
|
+
* machines) and replays the original request with
|
|
16
|
+
* `X-Verification-Token`. It is a detour, not a failure.
|
|
17
|
+
* 2. **An ENROLLMENT demand** — `verification.enroll === true` and there is
|
|
18
|
+
* NO challenge id, because the user holds no factor that could be
|
|
19
|
+
* challenged. Core deliberately does not intercept this one (there is
|
|
20
|
+
* nothing to drive), so it surfaces as a plain 403 and the app must send
|
|
21
|
+
* the user to factor enrollment first. {@link readVerificationEnrollment}
|
|
22
|
+
* is how a hook says "this is that, and these are the factors".
|
|
23
|
+
*
|
|
24
|
+
* KEEP IN SYNC with stapel-workspaces `capabilities.BUILTIN_CAPABILITY_LEVELS`
|
|
25
|
+
* — a PORT for the same reason `capabilityMatches` is one: `GET /roles`
|
|
26
|
+
* publishes each role's capabilities but not their levels, so the level map
|
|
27
|
+
* cannot be read off the wire. A deployment may raise further capabilities via
|
|
28
|
+
* `STAPEL_WORKSPACES["CAPABILITY_LEVELS"]`; those are additive, so treating an
|
|
29
|
+
* unlisted capability as `standard` can only ever under-promise (the backend
|
|
30
|
+
* still challenges, and core still drives it).
|
|
31
|
+
*/
|
|
32
|
+
import { isStapelApiError } from "@stapel/core";
|
|
33
|
+
|
|
34
|
+
/** The verification scope every `high` workspace capability is gated under. */
|
|
35
|
+
export const SENSITIVE_SCOPE = "sensitive";
|
|
36
|
+
|
|
37
|
+
/** Step-up level of a capability. */
|
|
38
|
+
export type CapabilityLevel = "standard" | "high";
|
|
39
|
+
|
|
40
|
+
/** PORT of `BUILTIN_CAPABILITY_LEVELS` — everything unlisted is `standard`. */
|
|
41
|
+
export const BUILTIN_CAPABILITY_LEVELS: Readonly<Record<string, "high">> = {
|
|
42
|
+
"members.provision": "high",
|
|
43
|
+
"members.password.reset": "high",
|
|
44
|
+
"workspace.security.manage": "high",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The declared step-up level of one capability string. */
|
|
48
|
+
export function capabilityLevel(capability: string): CapabilityLevel {
|
|
49
|
+
return BUILTIN_CAPABILITY_LEVELS[capability] ?? "standard";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The 403 ENROLLMENT envelope: the caller must first enroll one of `factors`
|
|
54
|
+
* — there is no challenge to complete, so core cannot drive it.
|
|
55
|
+
*/
|
|
56
|
+
export interface VerificationEnrollment {
|
|
57
|
+
/** What the demand protects — `"sensitive"` for every `high` capability. */
|
|
58
|
+
readonly scope: string;
|
|
59
|
+
/** Interchangeable factors, any ONE of which unblocks the operation. */
|
|
60
|
+
readonly factors: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
65
|
+
? (value as Record<string, unknown>)
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the enrollment demand out of a rejected request, or `null` when the
|
|
71
|
+
* error is anything else (including a step-up CHALLENGE, which carries a
|
|
72
|
+
* `challenge_id` and is core's business, not the consumer's).
|
|
73
|
+
*/
|
|
74
|
+
export function readVerificationEnrollment(
|
|
75
|
+
error: unknown
|
|
76
|
+
): VerificationEnrollment | null {
|
|
77
|
+
// Both dialects, narrowed through the imported predicate (never through a
|
|
78
|
+
// shape this call site invented): the typed error carries the parsed body,
|
|
79
|
+
// a rethrown raw envelope IS the body.
|
|
80
|
+
const body = asRecord(isStapelApiError(error) ? error.body : error);
|
|
81
|
+
if (body === null) return null;
|
|
82
|
+
const verification = asRecord(body["verification"]);
|
|
83
|
+
if (verification === null) return null;
|
|
84
|
+
if (verification["enroll"] !== true) return null;
|
|
85
|
+
if (typeof verification["challenge_id"] === "string") return null;
|
|
86
|
+
const factors = verification["factors"];
|
|
87
|
+
return {
|
|
88
|
+
scope:
|
|
89
|
+
typeof verification["scope"] === "string"
|
|
90
|
+
? verification["scope"]
|
|
91
|
+
: SENSITIVE_SCOPE,
|
|
92
|
+
factors: Array.isArray(factors)
|
|
93
|
+
? factors.filter((f): f is string => typeof f === "string")
|
|
94
|
+
: [],
|
|
95
|
+
};
|
|
96
|
+
}
|