From 73b190ac20fd819e2563105e5aa7eb810a1e3c83 Mon Sep 17 00:00:00 2001 From: doswalt Date: Tue, 15 Sep 2026 14:36:06 -0400 Subject: [PATCH 01/12] fetch multiple groupsets in ts lib, playground tester app --- clientlibs/js/ff-playground-app/.gitignore | 43 + clientlibs/js/ff-playground-app/README.md | 61 + clientlibs/js/ff-playground-app/angular.json | 109 + clientlibs/js/ff-playground-app/package.json | 39 + .../js/ff-playground-app/public/favicon.ico | Bin 0 -> 15086 bytes .../ff-playground-app/src/app/app.config.ts | 9 + .../js/ff-playground-app/src/app/app.ts | 282 ++ .../app/feature-flag-playground.service.ts | 160 + .../js/ff-playground-app/src/index.html | 13 + clientlibs/js/ff-playground-app/src/main.ts | 6 + .../js/ff-playground-app/src/styles.css | 1 + .../js/ff-playground-app/tsconfig.app.json | 15 + clientlibs/js/ff-playground-app/tsconfig.json | 31 + clientlibs/js/ff-playground-app/yarn.lock | 3597 +++++++++++++++++ .../js/src/ApiService/ApiService.spec.ts | 109 +- clientlibs/js/src/ApiService/ApiService.ts | 50 +- clientlibs/js/src/DataService/DataService.ts | 70 +- .../src/UpGradeClient/UpgradeClient.spec.ts | 552 ++- .../js/src/UpGradeClient/UpgradeClient.ts | 456 ++- clientlibs/js/src/types/Interfaces.ts | 61 +- clientlibs/js/src/types/requests.ts | 7 +- .../ExperimentClientController.v6.ts | 177 +- .../validators/FeatureFlagRequestValidator.ts | 153 +- .../api/middlewares/UserCheckMiddleware.ts | 100 +- .../src/api/services/FeatureFlagService.ts | 72 + packages/backend/src/types/index.ts | 4 + .../FeatureFlagRequestValidator.test.ts | 251 ++ .../middlewares/UserCheckMiddleware.test.ts | 136 + .../unit/services/FeatureFlagService.test.ts | 59 + postman/ClientAPI.postman_collection.json | 86 +- 30 files changed, 6299 insertions(+), 410 deletions(-) create mode 100644 clientlibs/js/ff-playground-app/.gitignore create mode 100644 clientlibs/js/ff-playground-app/README.md create mode 100644 clientlibs/js/ff-playground-app/angular.json create mode 100644 clientlibs/js/ff-playground-app/package.json create mode 100644 clientlibs/js/ff-playground-app/public/favicon.ico create mode 100644 clientlibs/js/ff-playground-app/src/app/app.config.ts create mode 100644 clientlibs/js/ff-playground-app/src/app/app.ts create mode 100644 clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts create mode 100644 clientlibs/js/ff-playground-app/src/index.html create mode 100644 clientlibs/js/ff-playground-app/src/main.ts create mode 100644 clientlibs/js/ff-playground-app/src/styles.css create mode 100644 clientlibs/js/ff-playground-app/tsconfig.app.json create mode 100644 clientlibs/js/ff-playground-app/tsconfig.json create mode 100644 clientlibs/js/ff-playground-app/yarn.lock create mode 100644 packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts diff --git a/clientlibs/js/ff-playground-app/.gitignore b/clientlibs/js/ff-playground-app/.gitignore new file mode 100644 index 0000000000..b1d225e26e --- /dev/null +++ b/clientlibs/js/ff-playground-app/.gitignore @@ -0,0 +1,43 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/clientlibs/js/ff-playground-app/README.md b/clientlibs/js/ff-playground-app/README.md new file mode 100644 index 0000000000..f0fe13955d --- /dev/null +++ b/clientlibs/js/ff-playground-app/README.md @@ -0,0 +1,61 @@ +# Feature Flag Playground + +A minimal Angular app for trying out the recipes in +[`../feature-flags-guide.md`](../feature-flags-guide.md) against a real, running UpGrade backend. + +There are only two source files: + +- `src/app/feature-flag-playground.service.ts` — **the file to edit.** All `upgrade_client_lib` + usage lives here. `login()` currently uses the recommended ephemeral-mode recipe for the square, + and an ad-hoc `groupsets` override for the circles. Alternate recipes (stored mode, merged mode) + are included as commented-out blocks — swap them in to see how behavior changes. +- `src/app/app.ts` — a single barebones component: a login form (user id + repeatable + group-type/values rows) and, once logged in, the two flag displays described below. + +## Prerequisites + +1. The UpGrade backend running locally at `http://localhost:3030` (`docker-compose up` from the + repo root, or however you normally run it). CORS already allows any `localhost` port, so the + Angular dev server's port doesn't matter. +2. Two feature flags created via the admin UI, both under context **`upgrade-internal`**: + - `SQUARE_COLOR_FLAG_FOR_USER` — drives the square. Include/exclude it by whatever + class/school/district/instructor groups you plan to log in with. + - `SCHOOL_CIRCLE_FLAG` — drives the per-schoolId circles. Since each circle is checked as its + own ephemeral groupset containing only that one schoolId, scope this flag's inclusion by + `schoolId` if you want some circles green and others red. + + Until these exist, everything will just show as "off" (red) — that's expected, not a bug. + +## Running it + +```bash +yarn install # first time only; also re-run this after rebuilding the SDK (see below) +yarn start # ng serve, on http://localhost:4300 (4200 is reserved for the UpGrade admin UI) +``` + +## Using it + +1. Enter a user id, or leave it blank to have one generated. +2. Add zero or more group rows — pick a type (`classId` / `schoolId` / `districtId` / + `instructorId`) and type comma-separated values. +3. Click **Log in**. This does not call `init()` — it configures an ephemeral-mode + `UpgradeClient` with exactly the groups you entered, and fetches both flags. +4. The square is colored by `SQUARE_COLOR_FLAG_FOR_USER` for the whole set of groups you entered. + The circles row shows one circle per `schoolId` you entered, each independently colored by + `SCHOOL_CIRCLE_FLAG` for that one schoolId. If you didn't enter any schoolIds, it says so + instead of showing an empty row. +5. **Log out** and repeat with different groups/ids to compare. + +## Picking up SDK changes + +This app depends on `upgrade_client_lib` via a local `file:` dependency pointing at +`../packages/full-browser` (which `npm run build` in `clientlibs/js` populates from `../dist/browser`). +After changing the client library source: + +```bash +cd ../ && npm run build # rebuilds clientlibs/js, including packages/full-browser +cd ff-playground-app && yarn install --offline # re-copies the local dependency +``` + +`yarn start` needs to be restarted afterward to pick up the change (Angular's dev server does not +watch outside `src/`). diff --git a/clientlibs/js/ff-playground-app/angular.json b/clientlibs/js/ff-playground-app/angular.json new file mode 100644 index 0000000000..e833d0c5dd --- /dev/null +++ b/clientlibs/js/ff-playground-app/angular.json @@ -0,0 +1,109 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "yarn" + }, + "newProjectRoot": "projects", + "projects": { + "ff-playground-app": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineTemplate": true, + "inlineStyle": true, + "skipTests": true + }, + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "allowedCommonJsDependencies": [ + "upgrade_client_lib" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "750kB", + "maximumError": "1.5MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "port": 4300 + }, + "configurations": { + "production": { + "buildTarget": "ff-playground-app:build:production" + }, + "development": { + "buildTarget": "ff-playground-app:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n" + } + } + } + } +} diff --git a/clientlibs/js/ff-playground-app/package.json b/clientlibs/js/ff-playground-app/package.json new file mode 100644 index 0000000000..240299d574 --- /dev/null +++ b/clientlibs/js/ff-playground-app/package.json @@ -0,0 +1,39 @@ +{ + "name": "ff-playground-app", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development" + }, + "prettier": { + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] + }, + "private": true, + "dependencies": { + "@angular/common": "^20.3.0", + "@angular/compiler": "^20.3.0", + "@angular/core": "^20.3.0", + "@angular/platform-browser": "^20.3.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "upgrade_client_lib": "file:../packages/full-browser", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular/build": "^20.3.32", + "@angular/cli": "^20.3.32", + "@angular/compiler-cli": "^20.3.0", + "typescript": "~5.9.2" + } +} \ No newline at end of file diff --git a/clientlibs/js/ff-playground-app/public/favicon.ico b/clientlibs/js/ff-playground-app/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/clientlibs/js/ff-playground-app/src/app/app.config.ts b/clientlibs/js/ff-playground-app/src/app/app.config.ts new file mode 100644 index 0000000000..b956a9e707 --- /dev/null +++ b/clientlibs/js/ff-playground-app/src/app/app.config.ts @@ -0,0 +1,9 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + + ] +}; diff --git a/clientlibs/js/ff-playground-app/src/app/app.ts b/clientlibs/js/ff-playground-app/src/app/app.ts new file mode 100644 index 0000000000..f42f1ae280 --- /dev/null +++ b/clientlibs/js/ff-playground-app/src/app/app.ts @@ -0,0 +1,282 @@ +import { Component, computed, effect, signal } from '@angular/core'; +import { FeatureFlagPlaygroundService, GroupType, SCHOOL_CIRCLE_FLAG, SQUARE_COLOR_FLAG } from './feature-flag-playground.service'; + +interface GroupRow { + type: GroupType; + valuesText: string; +} + +interface StoredForm { + userId: string; + rows: GroupRow[]; +} + +const GROUP_TYPES: GroupType[] = ['classId', 'schoolId', 'districtId', 'instructorId']; +const FORM_STORAGE_KEY = 'ff-playground-form'; + +// Remembers the last-entered userId + group rows across refreshes, purely as a form-fill +// convenience — it does not restore the logged-in session itself. +function loadStoredForm(): StoredForm { + try { + const raw = localStorage.getItem(FORM_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return { + userId: typeof parsed?.userId === 'string' ? parsed.userId : '', + rows: Array.isArray(parsed?.rows) ? parsed.rows : [], + }; + } catch { + return { userId: '', rows: [] }; + } +} + +function generateUUID(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +@Component({ + selector: 'app-root', + imports: [], + template: ` +
+

Feature Flag Playground

+

+ Exercises the recipes from feature-flags-guide.md against a real UpGrade + backend. See feature-flag-playground.service.ts to change which recipe runs. +

+ + @if (!ff.loggedIn()) { +
+

Log in

+ + + +

Groups

+ @for (row of rows(); track $index) { +
+ + + +
+ } + + +
+ +
+ + @if (ff.error()) { +

{{ ff.error() }}

+ } +
+ } @else { +
+

Session

+

userId: {{ ff.userId() }}

+

useGroups: {{ groupsJson() }}

+ +
+ +
+

{{ squareFlagKey }}

+
+ {{ ff.squareEnabled() ? 'ON' : 'OFF' }} +
+
+ +
+

{{ circleFlagKey }} (by schoolId)

+ @if (ff.schoolIds().length === 0) { +

User has no schoolIds associated.

+ } @else { +
+ @for (schoolId of ff.schoolIds(); track schoolId) { +
+
+ {{ schoolId }} +
+ } +
+ } +
+ } +
+ `, + styles: [ + ` + main { + max-width: 640px; + margin: 2rem auto; + padding: 0 1rem; + font-family: system-ui, sans-serif; + } + .subtitle { + color: #666; + font-size: 0.9rem; + } + .card { + border: 1px solid #ddd; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; + } + .row { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0.5rem 0; + } + label { + display: block; + font-weight: 600; + margin-bottom: 0.5rem; + } + input, + select { + padding: 0.4rem; + font-size: 1rem; + } + input[type='text'], + input:not([type]) { + flex: 1; + } + button { + padding: 0.4rem 0.8rem; + cursor: pointer; + } + button.primary { + background: #2b6cb0; + color: white; + border: none; + border-radius: 4px; + } + .actions { + margin-top: 1rem; + } + .error { + color: #c0392b; + } + .square { + width: 140px; + height: 140px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + } + .square.on { + background: #2ecc71; + } + .square.off { + background: #e74c3c; + } + .circles { + display: flex; + flex-wrap: wrap; + gap: 1rem; + } + .circle-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + } + .circle { + width: 48px; + height: 48px; + border-radius: 50%; + } + .circle.on { + background: #2ecc71; + } + .circle.off { + background: #e74c3c; + } + `, + ], +}) +export class App { + readonly squareFlagKey = SQUARE_COLOR_FLAG; + readonly circleFlagKey = SCHOOL_CIRCLE_FLAG; + readonly groupTypes = GROUP_TYPES; + + readonly userIdInput = signal(loadStoredForm().userId); + readonly rows = signal(loadStoredForm().rows); + + constructor(public ff: FeatureFlagPlaygroundService) { + effect(() => { + const form: StoredForm = { userId: this.userIdInput(), rows: this.rows() }; + try { + localStorage.setItem(FORM_STORAGE_KEY, JSON.stringify(form)); + } catch { + // localStorage unavailable (private browsing, etc.) — nothing to persist, ignore. + } + }); + } + + generateUUID = generateUUID; + readonly groupsJson = computed(() => JSON.stringify(this.ff.allGroups())); + + input(event: Event): string { + return (event.target as HTMLInputElement | HTMLSelectElement).value; + } + + addRow(): void { + this.rows.update((rows) => [...rows, { type: 'schoolId', valuesText: '' }]); + } + + removeRow(index: number): void { + this.rows.update((rows) => rows.filter((_, i) => i !== index)); + } + + setRowType(index: number, type: string): void { + this.rows.update((rows) => rows.map((row, i) => (i === index ? { ...row, type: type as GroupType } : row))); + } + + setRowValues(index: number, valuesText: string): void { + this.rows.update((rows) => rows.map((row, i) => (i === index ? { ...row, valuesText } : row))); + } + + async login(): Promise { + const userId = this.userIdInput().trim() || generateUUID(); + const groups = this.rows().map((row) => ({ + type: row.type, + values: row.valuesText + .split(',') + .map((v) => v.trim()) + .filter(Boolean), + })); + + await this.ff.login(userId, groups); + } + + logout(): void { + this.ff.logout(); + this.userIdInput.set(''); + this.rows.set([]); + } +} diff --git a/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts new file mode 100644 index 0000000000..e799b56495 --- /dev/null +++ b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts @@ -0,0 +1,160 @@ +import { Injectable, signal } from '@angular/core'; +import UpgradeClient, { UpGradeClientInterfaces } from 'upgrade_client_lib'; + +/** + * ============================================================================ + * PLAYGROUND SERVICE + * ============================================================================ + * This is the one file meant for hands-on experimentation with the + * upgrade_client_lib feature-flag API. See ../../../docs/feature-flags-guide.md + * for the full write-up of every mode used here. + * + * The component (app.ts) only ever calls `login()`/`logout()` and reads the + * signals below — all the actual client-library usage lives in this file. + * Edit the marked blocks in `login()` to try the alternate recipes. + * ============================================================================ + */ + +export type GroupType = 'schoolId' | 'classId' | 'districtId' | 'instructorId'; + +export interface GroupEntry { + type: GroupType; + values: string[]; +} + +// Point these at whatever backend/context you're testing against. +const HOST_URL = 'http://localhost:3030'; +const CONTEXT = 'upgrade-internal'; + +// The two flags this playground exercises. Create both, under the context +// above, via the admin UI before expecting anything but "off" from either. +export const SQUARE_COLOR_FLAG = 'SQUARE_COLOR_FLAG_FOR_USER'; +export const SCHOOL_CIRCLE_FLAG = 'SCHOOL_CIRCLE_FLAG'; + +@Injectable({ providedIn: 'root' }) +export class FeatureFlagPlaygroundService { + private client: UpgradeClient | null = null; + + readonly loggedIn = signal(false); + readonly loading = signal(false); + readonly error = signal(null); + + readonly userId = signal(''); + readonly allGroups = signal>({}); + + readonly squareEnabled = signal(false); + readonly schoolIds = signal([]); + readonly schoolCircleStates = signal>({}); + + async login(userId: string, groups: GroupEntry[]): Promise { + this.loading.set(true); + this.error.set(null); + + const allGroups: Record = {}; + for (const { type, values } of groups) { + if (values.length) { + allGroups[type] = values; + } + } + const schoolIds = allGroups['schoolId'] ?? []; + const fullGroupset = { groups: allGroups }; + const schoolSubGroupsets = schoolIds.map((schoolId) => ({ + groupsetId: schoolId, + groups: { schoolId: [schoolId] }, + })); + + const featureFlagGroupOptions: UpGradeClientInterfaces.IFeatureFlagGroupOptions = { + useMultipleGroupSets: { + mainGroupset: fullGroupset, + subGroupsets: schoolSubGroupsets, + }, + // useSingleGroupSet: { groups: fullGroupset, includeStoredUserGroups: false }, // alias for current behavior + }; + + // deprecated but still valid, no plans to remove: + + // const featureFlagGroupOptions: UpGradeClientInterfaces.IFeatureFlagOptions = { + // groupsForSession: fullGroupset, + // includeStoredUserGroups: true, + // }; + + try { + 3; + // ---------------------------------------------------------------------- + // RECOMMENDED PATH — one groupset configuration, one network call. The + // SQUARE feature reads the mainGroupset (the "whole user" view built + // from everything just typed in); each school's CIRCLE reads its own + // named subGroupset. useMultipleGroupSets.subGroupsets can't be empty, + // so with no schoolIds this falls back to a plain useSingleGroupSet — + // still ephemeral, still one call, just nothing to fan out over. + // ---------------------------------------------------------------------- + + this.client = new UpgradeClient(userId, HOST_URL, CONTEXT, { + featureFlagGroupOptions: featureFlagGroupOptions, + }); + + // this.client = new UpgradeClient(userId, HOST_URL, CONTEXT, { + // featureFlagUserGroupsForSession: featureFlagGroupOptions, + // }); + + // ---------------------------------------------------------------------- + // ALTERNATE RECIPES — comment out the block above and uncomment one of + // these to see how the square's behavior changes. Both require the + // backend to already have an initialized user (they'll otherwise 404). + // ---------------------------------------------------------------------- + // + // STORED MODE — ignores `useGroups` above entirely; uses whatever + // groups are already stored for this user id: + // + // this.client = new UpgradeClient(userId, HOST_URL, CONTEXT); + // await this.client.init(); + // + // MERGED MODE — stored groups, plus these as overrides on top, as a + // single groupset (no per-school subGroupsets, so the square is the + // only thing this affects): + // + // this.client = new UpgradeClient(userId, HOST_URL, CONTEXT); + // await this.client.init(); + // this.client.setFeatureFlagGroupOptions({ + // useSingleGroupSet: { groups: useGroups, includeStoredUserGroups: true }, + // }); + + // One call fetches everything configured above — mainGroupset (or the + // single groupset) plus every subGroupset — in a single HTTP request. + await this.client.getAllFeatureFlags(); + + // hasFeatureFlag(key) with no id resolves to the mainGroupset/single + // groupset; hasFeatureFlag(key, schoolId) reads the matching + // subGroupset. Both are served from the cache the call above populated + // — no further network calls. + this.squareEnabled.set(await this.client.hasFeatureFlag(SQUARE_COLOR_FLAG)); + + const states: Record = {}; + for (const schoolId of schoolIds) { + states[schoolId] = await this.client.hasFeatureFlag(SCHOOL_CIRCLE_FLAG, schoolId); + } + this.schoolCircleStates.set(states); + + this.userId.set(userId); + this.allGroups.set(allGroups); + this.schoolIds.set(schoolIds); + this.loggedIn.set(true); + } catch (err) { + this.error.set(err instanceof Error ? err.message : String(err)); + this.client = null; + } finally { + this.loading.set(false); + } + } + + logout(): void { + this.client = null; + this.loggedIn.set(false); + this.error.set(null); + this.userId.set(''); + this.allGroups.set({}); + this.squareEnabled.set(false); + this.schoolIds.set([]); + this.schoolCircleStates.set({}); + } +} diff --git a/clientlibs/js/ff-playground-app/src/index.html b/clientlibs/js/ff-playground-app/src/index.html new file mode 100644 index 0000000000..c238b3e3ba --- /dev/null +++ b/clientlibs/js/ff-playground-app/src/index.html @@ -0,0 +1,13 @@ + + + + + FfPlaygroundApp + + + + + + + + diff --git a/clientlibs/js/ff-playground-app/src/main.ts b/clientlibs/js/ff-playground-app/src/main.ts new file mode 100644 index 0000000000..5df75f9c83 --- /dev/null +++ b/clientlibs/js/ff-playground-app/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/clientlibs/js/ff-playground-app/src/styles.css b/clientlibs/js/ff-playground-app/src/styles.css new file mode 100644 index 0000000000..90d4ee0072 --- /dev/null +++ b/clientlibs/js/ff-playground-app/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/clientlibs/js/ff-playground-app/tsconfig.app.json b/clientlibs/js/ff-playground-app/tsconfig.app.json new file mode 100644 index 0000000000..264f459bf8 --- /dev/null +++ b/clientlibs/js/ff-playground-app/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/clientlibs/js/ff-playground-app/tsconfig.json b/clientlibs/js/ff-playground-app/tsconfig.json new file mode 100644 index 0000000000..5921438a8b --- /dev/null +++ b/clientlibs/js/ff-playground-app/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/clientlibs/js/ff-playground-app/yarn.lock b/clientlibs/js/ff-playground-app/yarn.lock new file mode 100644 index 0000000000..10f1b80edb --- /dev/null +++ b/clientlibs/js/ff-playground-app/yarn.lock @@ -0,0 +1,3597 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@algolia/abtesting@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@algolia/abtesting/-/abtesting-1.1.0.tgz#32c84876956727dcdca8df03c567f12fb39edf0d" + integrity sha512-sEyWjw28a/9iluA37KLGu8vjxEIlb60uxznfTUmXImy7H5NvbpSO6yYgmgH5KiD7j+zTUUihiST0jEP12IoXow== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-abtesting@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.35.0.tgz#e4cb9ad90357c8b26fb6fbcd0157cc8bd1b600ed" + integrity sha512-uUdHxbfHdoppDVflCHMxRlj49/IllPwwQ2cQ8DLC4LXr3kY96AHBpW0dMyi6ygkn2MtFCc6BxXCzr668ZRhLBQ== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-analytics@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.35.0.tgz#ff0b9d9cbda074472d727a2d0a6b18bcc47076be" + integrity sha512-SunAgwa9CamLcRCPnPHx1V2uxdQwJGqb1crYrRWktWUdld0+B2KyakNEeVn5lln4VyeNtW17Ia7V7qBWyM/Skw== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-common@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.35.0.tgz#1d72b36308ceaa53d7d488cd3a161506ddeb0c42" + integrity sha512-ipE0IuvHu/bg7TjT2s+187kz/E3h5ssfTtjpg1LbWMgxlgiaZIgTTbyynM7NfpSJSKsgQvCQxWjGUO51WSCu7w== + +"@algolia/client-insights@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.35.0.tgz#8fd51eead7d89925522d48b5c164b3d7b656707c" + integrity sha512-UNbCXcBpqtzUucxExwTSfAe8gknAJ485NfPN6o1ziHm6nnxx97piIbcBQ3edw823Tej2Wxu1C0xBY06KgeZ7gA== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-personalization@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.35.0.tgz#a661767851269b8ff091eaa900fe0b65b3937382" + integrity sha512-/KWjttZ6UCStt4QnWoDAJ12cKlQ+fkpMtyPmBgSS2WThJQdSV/4UWcqCUqGH7YLbwlj3JjNirCu3Y7uRTClxvA== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-query-suggestions@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.35.0.tgz#b8704f3cdb2b49b8f0348bb77a3821a5ffa5b73b" + integrity sha512-8oCuJCFf/71IYyvQQC+iu4kgViTODbXDk3m7yMctEncRSRV+u2RtDVlpGGfPlJQOrAY7OONwJlSHkmbbm2Kp/w== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-search@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.35.0.tgz#61441c4de212d8a1af93ab1406f46fd3386c35a0" + integrity sha512-FfmdHTrXhIduWyyuko1YTcGLuicVbhUyRjO3HbXE4aP655yKZgdTIfMhZ/V5VY9bHuxv/fGEh3Od1Lvv2ODNTg== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/ingestion@1.35.0": + version "1.35.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.35.0.tgz#fb263cc190da421756f8f52e3fe5e5afdc89f68c" + integrity sha512-gPzACem9IL1Co8mM1LKMhzn1aSJmp+Vp434An4C0OBY4uEJRcqsLN3uLBlY+bYvFg8C8ImwM9YRiKczJXRk0XA== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/monitoring@1.35.0": + version "1.35.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.35.0.tgz#6be2b3e2a48328b57d98827da9c85ec2b42d49da" + integrity sha512-w9MGFLB6ashI8BGcQoVt7iLgDIJNCn4OIu0Q0giE3M2ItNrssvb8C0xuwJQyTy1OFZnemG0EB1OvXhIHOvQwWw== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/recommend@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.35.0.tgz#03c3d1dbbd1b5b07dda98350b45640c819081211" + integrity sha512-AhrVgaaXAb8Ue0u2nuRWwugt0dL5UmRgS9LXe0Hhz493a8KFeZVUE56RGIV3hAa6tHzmAV7eIoqcWTQvxzlJeQ== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/requester-browser-xhr@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.35.0.tgz#b1c26fc9be80a0ddde6b2e45fb50d52c7c6c05f4" + integrity sha512-diY415KLJZ6x1Kbwl9u96Jsz0OstE3asjXtJ9pmk1d+5gPuQ5jQyEsgC+WmEXzlec3iuVszm8AzNYYaqw6B+Zw== + dependencies: + "@algolia/client-common" "5.35.0" + +"@algolia/requester-fetch@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.35.0.tgz#29815c5bf501148ea619ed96bae09c099a5cced4" + integrity sha512-uydqnSmpAjrgo8bqhE9N1wgcB98psTRRQXcjc4izwMB7yRl9C8uuAQ/5YqRj04U0mMQ+fdu2fcNF6m9+Z1BzDQ== + dependencies: + "@algolia/client-common" "5.35.0" + +"@algolia/requester-node-http@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.35.0.tgz#9095b80a0ae77f574036261f44053636e31d1baf" + integrity sha512-RgLX78ojYOrThJHrIiPzT4HW3yfQa0D7K+MQ81rhxqaNyNBu4F1r+72LNHYH/Z+y9I1Mrjrd/c/Ue5zfDgAEjQ== + dependencies: + "@algolia/client-common" "5.35.0" + +"@ampproject/remapping@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@angular-devkit/architect@0.2003.32": + version "0.2003.32" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.32.tgz#197a119e742b72f6dbc26e0a7e15dce756922568" + integrity sha512-V5d531w97LENARKdYk5Km0F2rt8NPEL3rXUQk7+gbo9r/jgLWn9GU3VHQ30oBWXnU7Vu00Hdp/8yFeYgpWqSow== + dependencies: + "@angular-devkit/core" "20.3.32" + rxjs "7.8.2" + +"@angular-devkit/core@20.3.32": + version "20.3.32" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.32.tgz#463acaa6a7a599d9d9c54e97b6ecaf8c816fdd77" + integrity sha512-pXipSsYP/XTEAljmwqgy1u3GR/ZzSMg1FERINekGT61Th1pGhzjs02rPgbyftqz2e5/tfQ6XgTP7xYzm3+S35Q== + dependencies: + ajv "8.18.0" + ajv-formats "3.0.1" + jsonc-parser "3.3.1" + picomatch "4.0.4" + rxjs "7.8.2" + source-map "0.7.6" + +"@angular-devkit/schematics@20.3.32": + version "20.3.32" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.32.tgz#d4edd71bc0a7df41c6d70609e56e0afa9b1a912e" + integrity sha512-UxWncmJTcYMDEaAKRi3QHU3qXivIcIOulFOKozW8svfs/A43Oq1GG4kvDZ+WVf/w2UWTmMaSlfFa4KIQciSIDA== + dependencies: + "@angular-devkit/core" "20.3.32" + jsonc-parser "3.3.1" + magic-string "0.30.17" + ora "8.2.0" + rxjs "7.8.2" + +"@angular/build@^20.3.32": + version "20.3.32" + resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.32.tgz#7e7f60f458e53f34aae9ca7a62a72d9954c009d1" + integrity sha512-ph/qcT6C7RYriU5dxXfNrYBEvCwU4acxTNoxkdGQHYxM7iOKT0K1YO6v5JBNRZ1S4LOJhWmGaWzJ+sRei0iGsQ== + dependencies: + "@ampproject/remapping" "2.3.0" + "@angular-devkit/architect" "0.2003.32" + "@babel/core" "7.29.7" + "@babel/helper-annotate-as-pure" "7.27.3" + "@babel/helper-split-export-declaration" "7.24.7" + "@inquirer/confirm" "5.1.14" + "@vitejs/plugin-basic-ssl" "2.1.0" + beasties "0.3.5" + browserslist "^4.23.0" + esbuild "0.28.1" + https-proxy-agent "7.0.6" + istanbul-lib-instrument "6.0.3" + jsonc-parser "3.3.1" + listr2 "9.0.1" + magic-string "0.30.17" + mrmime "2.0.1" + parse5-html-rewriting-stream "8.0.0" + picomatch "4.0.4" + piscina "5.2.0" + rollup "4.59.0" + sass "1.90.0" + semver "7.7.2" + source-map-support "0.5.21" + tinyglobby "0.2.14" + vite "7.3.6" + watchpack "2.4.4" + optionalDependencies: + lmdb "3.4.2" + +"@angular/cli@^20.3.32": + version "20.3.32" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.32.tgz#11bb9ab92cbfa8325c7234014ff48df5466062c6" + integrity sha512-WIMbTrEJYVVz4Phs8nn6yK/bzCSt1dDlxtAEnS2melTWXKGF6WK/OqVaH0+Cox0SNagA81dvvSbGlFYCxncYfQ== + dependencies: + "@angular-devkit/architect" "0.2003.32" + "@angular-devkit/core" "20.3.32" + "@angular-devkit/schematics" "20.3.32" + "@inquirer/prompts" "7.8.2" + "@listr2/prompt-adapter-inquirer" "3.0.1" + "@modelcontextprotocol/sdk" "1.26.0" + "@schematics/angular" "20.3.32" + "@yarnpkg/lockfile" "1.1.0" + algoliasearch "5.35.0" + ini "5.0.0" + jsonc-parser "3.3.1" + listr2 "9.0.1" + npm-package-arg "13.0.0" + pacote "21.5.1" + resolve "1.22.10" + semver "7.7.2" + yargs "18.0.0" + zod "4.1.13" + +"@angular/common@^20.3.0": + version "20.3.27" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.27.tgz#52405b538e1e662b4b93a4176d6ff86fbb81f7e7" + integrity sha512-4ectYP60XatB9zZ40WlfmaTzjmEhaz8SSqLsbZI4VZ8gDb5qNmxWtwwt8UxS3NmDHEgqdNL8UPO4E94+yKCICg== + dependencies: + tslib "^2.3.0" + +"@angular/compiler-cli@^20.3.0": + version "20.3.26" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.26.tgz#a0a27b37e4e9424721b4eaf79f7390a3c9f72b39" + integrity sha512-3rHtC87ecldvaiFHwQEZ6Wx3QaZ/Q7b0Gb7XORDOjn/M+5CYZ4rQsvbxE5TUjwreg07oQ4Y2h8ADESXTJEUYOQ== + dependencies: + "@babel/core" "7.29.7" + "@jridgewell/sourcemap-codec" "^1.4.14" + chokidar "^4.0.0" + convert-source-map "^1.5.1" + reflect-metadata "^0.2.0" + semver "^7.0.0" + tslib "^2.3.0" + yargs "^18.0.0" + +"@angular/compiler@^20.3.0": + version "20.3.27" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.27.tgz#052a968f3830b9279d299b7ee0d1f248bdf0dc23" + integrity sha512-in3THZ678GAYuOR9RZV18+zZz0KGhlGikyUEfLeALLjGf9ZaR3n+t19BmYx6G2VkF/Xqadne1omQ2vbl6PRASA== + dependencies: + tslib "^2.3.0" + +"@angular/core@^20.3.0": + version "20.3.27" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.27.tgz#97ce97a90c3e06564df7d7d8abbb2eeac3afdb07" + integrity sha512-8EfYIUST5CKldOF4MAYWTFFRB7EtwqUoQBZdar6US39/EEzWm/wm/iRNkH2jmKe4YuPa2GoeHS1WQ8VRuOk7Dg== + dependencies: + tslib "^2.3.0" + +"@angular/platform-browser@^20.3.0": + version "20.3.26" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.26.tgz#29878dbdf52b2355ab002e7a29f71cfd19ec8a3e" + integrity sha512-In4wUiLUUT9LqyV9Rjz78k/dsnKAwec4AtDmwZoX8/ZmeJOSH7g5X1gTM+hxTxmifmZkrapQjXR299IcfIkzrw== + dependencies: + tslib "^2.3.0" + +"@babel/code-frame@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" + integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== + +"@babel/core@7.29.7", "@babel/core@^7.23.9": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" + integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== + dependencies: + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-annotate-as-pure@7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" + integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg== + dependencies: + "@babel/types" "^7.27.3" + +"@babel/helper-compilation-targets@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" + integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== + dependencies: + "@babel/compat-data" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b" + integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== + +"@babel/helper-module-imports@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396" + integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/helper-module-transforms@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae" + integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== + dependencies: + "@babel/helper-module-imports" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/traverse" "^7.29.7" + +"@babel/helper-split-export-declaration@7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz#83949436890e07fa3d6873c61a96e3bbf692d856" + integrity sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA== + dependencies: + "@babel/types" "^7.24.7" + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/helper-validator-option@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" + integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== + +"@babel/helpers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== + dependencies: + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/parser@^7.23.9", "@babel/parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + +"@babel/template@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/traverse@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d" + integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-globals" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + debug "^4.3.1" + +"@babel/types@^7.24.7", "@babel/types@^7.27.3", "@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@esbuild/aix-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" + integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== + +"@esbuild/android-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" + integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== + +"@esbuild/android-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" + integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== + +"@esbuild/android-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" + integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== + +"@esbuild/darwin-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" + integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== + +"@esbuild/darwin-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" + integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== + +"@esbuild/freebsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" + integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== + +"@esbuild/freebsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" + integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== + +"@esbuild/linux-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" + integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== + +"@esbuild/linux-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" + integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== + +"@esbuild/linux-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" + integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== + +"@esbuild/linux-loong64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" + integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== + +"@esbuild/linux-mips64el@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" + integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== + +"@esbuild/linux-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" + integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== + +"@esbuild/linux-riscv64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" + integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== + +"@esbuild/linux-s390x@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" + integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== + +"@esbuild/linux-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" + integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== + +"@esbuild/netbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" + integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== + +"@esbuild/netbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" + integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== + +"@esbuild/openbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" + integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== + +"@esbuild/openbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" + integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== + +"@esbuild/openharmony-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" + integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== + +"@esbuild/sunos-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" + integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== + +"@esbuild/win32-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" + integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== + +"@esbuild/win32-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" + integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== + +"@esbuild/win32-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" + integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== + +"@gar/promise-retry@^1.0.0", "@gar/promise-retry@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@gar/promise-retry/-/promise-retry-1.0.3.tgz#65e726428e794bc4453948e0a41e6de4215ce8b0" + integrity sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA== + +"@hono/node-server@^1.19.9": + version "1.19.17" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.17.tgz#99bd8625cdbae82652a135c71363cd6322fae483" + integrity sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ== + +"@inquirer/ansi@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@inquirer/ansi/-/ansi-1.0.2.tgz#674a4c4d81ad460695cb2a1fc69d78cd187f337e" + integrity sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ== + +"@inquirer/checkbox@^4.2.1": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@inquirer/checkbox/-/checkbox-4.3.2.tgz#e1483e6519d6ffef97281a54d2a5baa0d81b3f3b" + integrity sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/core" "^10.3.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/confirm@5.1.14": + version "5.1.14" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-5.1.14.tgz#e6321edf51a3a5f54dc548b80ef6ba89891351ad" + integrity sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q== + dependencies: + "@inquirer/core" "^10.1.15" + "@inquirer/type" "^3.0.8" + +"@inquirer/confirm@^5.1.14": + version "5.1.21" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-5.1.21.tgz#610c4acd7797d94890a6e2dde2c98eb1e891dd12" + integrity sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/core@^10.1.15", "@inquirer/core@^10.3.2": + version "10.3.2" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-10.3.2.tgz#535979ff3ff4fe1e7cc4f83e2320504c743b7e20" + integrity sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" + cli-width "^4.1.0" + mute-stream "^2.0.0" + signal-exit "^4.1.0" + wrap-ansi "^6.2.0" + yoctocolors-cjs "^2.1.3" + +"@inquirer/editor@^4.2.17": + version "4.2.23" + resolved "https://registry.yarnpkg.com/@inquirer/editor/-/editor-4.2.23.tgz#fe046a3bfdae931262de98c1052437d794322e0b" + integrity sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/external-editor" "^1.0.3" + "@inquirer/type" "^3.0.10" + +"@inquirer/expand@^4.0.17": + version "4.0.23" + resolved "https://registry.yarnpkg.com/@inquirer/expand/-/expand-4.0.23.tgz#a38b5f32226d75717c370bdfed792313b92bdc05" + integrity sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/external-editor@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@inquirer/external-editor/-/external-editor-1.0.3.tgz#c23988291ee676290fdab3fd306e64010a6d13b8" + integrity sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA== + dependencies: + chardet "^2.1.1" + iconv-lite "^0.7.0" + +"@inquirer/figures@^1.0.15": + version "1.0.15" + resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.15.tgz#dbb49ed80df11df74268023b496ac5d9acd22b3a" + integrity sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g== + +"@inquirer/input@^4.2.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-4.3.1.tgz#778683b4c4c4d95d05d4b05c4a854964b73565b4" + integrity sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/number@^3.0.17": + version "3.0.23" + resolved "https://registry.yarnpkg.com/@inquirer/number/-/number-3.0.23.tgz#3fdec2540d642093fd7526818fd8d4bdc7335094" + integrity sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/password@^4.0.17": + version "4.0.23" + resolved "https://registry.yarnpkg.com/@inquirer/password/-/password-4.0.23.tgz#b9f5187c8c92fd7aa9eceb9d8f2ead0d7e7b000d" + integrity sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/prompts@7.8.2": + version "7.8.2" + resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-7.8.2.tgz#5d9d8d7273831bd512e9cfaf3d827cce0f2eae0f" + integrity sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw== + dependencies: + "@inquirer/checkbox" "^4.2.1" + "@inquirer/confirm" "^5.1.14" + "@inquirer/editor" "^4.2.17" + "@inquirer/expand" "^4.0.17" + "@inquirer/input" "^4.2.1" + "@inquirer/number" "^3.0.17" + "@inquirer/password" "^4.0.17" + "@inquirer/rawlist" "^4.1.5" + "@inquirer/search" "^3.1.0" + "@inquirer/select" "^4.3.1" + +"@inquirer/rawlist@^4.1.5": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@inquirer/rawlist/-/rawlist-4.1.11.tgz#313c8c3ffccb7d41e990c606465726b4a898a033" + integrity sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/search@^3.1.0": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@inquirer/search/-/search-3.2.2.tgz#4cc6fd574dcd434e4399badc37c742c3fd534ac8" + integrity sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/select@^4.3.1": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@inquirer/select/-/select-4.4.2.tgz#2ac8fca960913f18f1d1b35323ed8fcd27d89323" + integrity sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/core" "^10.3.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/type@^3.0.10", "@inquirer/type@^3.0.7", "@inquirer/type@^3.0.8": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-3.0.10.tgz#11ed564ec78432a200ea2601a212d24af8150d50" + integrity sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA== + +"@isaacs/fs-minipass@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" + integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w== + dependencies: + minipass "^7.0.4" + +"@istanbuljs/schema@^0.1.3": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.6.tgz#8dc9afa2ac1506cb1a58f89940f1c124446c8df3" + integrity sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw== + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@listr2/prompt-adapter-inquirer@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz#7e081b4dcde2f1bd5ac16c620ee2402908f03fe7" + integrity sha512-3XFmGwm3u6ioREG+ynAQB7FoxfajgQnMhIu8wC5eo/Lsih4aKDg0VuIMGaOsYn7hJSJagSeaD4K8yfpkEoDEmA== + dependencies: + "@inquirer/type" "^3.0.7" + +"@lmdb/lmdb-darwin-arm64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.4.2.tgz#316848b41e26cf0ddb379fad193d49052f09d7df" + integrity sha512-NK80WwDoODyPaSazKbzd3NEJ3ygePrkERilZshxBViBARNz21rmediktGHExoj9n5t9+ChlgLlxecdFKLCuCKg== + +"@lmdb/lmdb-darwin-x64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.4.2.tgz#c2048ad9d4c4c23e420f8fb2f3c418f713715cb7" + integrity sha512-zevaowQNmrp3U7Fz1s9pls5aIgpKRsKb3dZWDINtLiozh3jZI9fBrI19lYYBxqdyiIyNdlyiidPnwPShj4aK+w== + +"@lmdb/lmdb-linux-arm64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.4.2.tgz#382a820e422c189feb0b64ce6ab2ad379ba7b097" + integrity sha512-ZBEfbNZdkneebvZs98Lq30jMY8V9IJzckVeigGivV7nTHJc+89Ctomp1kAIWKlwIG0ovCDrFI448GzFPORANYg== + +"@lmdb/lmdb-linux-arm@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.4.2.tgz#a3dbb70e1a1e999d7c82ccff3d6c938872241acd" + integrity sha512-OmHCULY17rkx/RoCoXlzU7LyR8xqrksgdYWwtYa14l/sseezZ8seKWXcogHcjulBddER5NnEFV4L/Jtr2nyxeg== + +"@lmdb/lmdb-linux-x64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.4.2.tgz#aa073eac33ede369d51afd0314b2e2af06a368e0" + integrity sha512-vL9nM17C77lohPYE4YaAQvfZCSVJSryE4fXdi8M7uWPBnU+9DJabgKVAeyDb84ZM2vcFseoBE4/AagVtJeRE7g== + +"@lmdb/lmdb-win32-arm64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.4.2.tgz#7e73500f0c81ca269a6ad9290fb1e7bf7137863d" + integrity sha512-SXWjdBfNDze4ZPeLtYIzsIeDJDJ/SdsA0pEXcUBayUIMO0FQBHfVZZyHXQjjHr4cvOAzANBgIiqaXRwfMhzmLw== + +"@lmdb/lmdb-win32-x64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.4.2.tgz#99caf114bb74d39c9e1baca99b907353ad8bf461" + integrity sha512-IY+r3bxKW6Q6sIPiMC0L533DEfRJSXibjSI3Ft/w9Q8KQBNqEIvUFXt+09wV8S5BRk0a8uSF19YWxuRwEfI90g== + +"@modelcontextprotocol/sdk@1.26.0": + version "1.26.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz#5b35d73062125f126cc70b0be83cbab53bcdde74" + integrity sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg== + dependencies: + "@hono/node-server" "^1.19.9" + ajv "^8.17.1" + ajv-formats "^3.0.1" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.2.1" + express-rate-limit "^8.2.1" + hono "^4.11.4" + jose "^6.1.3" + json-schema-typed "^8.0.2" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.25 || ^4.0" + zod-to-json-schema "^3.25.1" + +"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz#22619f76a6b10ba78c8b74025b0d9754cad69cc7" + integrity sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ== + +"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz#c2fc0573afe08b0cf213e66eef76842b121d1577" + integrity sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w== + +"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz#4e3822f5522e18ed92611b894dc5db1bc882f39d" + integrity sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw== + +"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz#27ec4bc7eb6c311c982a50f1a6e1e1414638a6f8" + integrity sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw== + +"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz#37317d833c7d01d086f6155fa59c478adb6839f6" + integrity sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ== + +"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz#a1c79dcc9ae5f8c02aea8c2f144e5af6a822e5e8" + integrity sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ== + +"@napi-rs/nice-android-arm-eabi@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz#4ebd966821cd6c2cc7cc020eb468de397bb9b40f" + integrity sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw== + +"@napi-rs/nice-android-arm64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz#e183ba874512bc005852daab8b78c63e0a4288a8" + integrity sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw== + +"@napi-rs/nice-darwin-arm64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz#64b1585809774cbb8bf95cea3d4c8827c9897394" + integrity sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A== + +"@napi-rs/nice-darwin-x64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz#99c0c7f62cb1e23ca76881bb29cc6000aeccc6f0" + integrity sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ== + +"@napi-rs/nice-freebsd-x64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz#9a5ca0e3ced86207887c98a5a560de8cde5a909e" + integrity sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ== + +"@napi-rs/nice-linux-arm-gnueabihf@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz#b8a6a1bc88d0de3e99ac3fdea69980dc6e20b502" + integrity sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg== + +"@napi-rs/nice-linux-arm64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz#226f1ef30fcb80fa40370e843b75cc86e39e1183" + integrity sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ== + +"@napi-rs/nice-linux-arm64-musl@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz#01345c3db79210ba5406c8729e8db75ed11c5f14" + integrity sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg== + +"@napi-rs/nice-linux-ppc64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz#ce7a1025227daab491ded40784b561394d688fcb" + integrity sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg== + +"@napi-rs/nice-linux-riscv64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz#9bef5dc89a0425d03163853b4968dbb686d98fd5" + integrity sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw== + +"@napi-rs/nice-linux-s390x-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz#247c8c7c45876877bdb337cfeb290ff4fd82de62" + integrity sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ== + +"@napi-rs/nice-linux-x64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz#7fd1f5e037cb44ab4f5f95a3b3225a99e3248f12" + integrity sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg== + +"@napi-rs/nice-linux-x64-musl@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz#d447cd7157ae5da5c0b15fc618bf61f0c344ff6f" + integrity sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw== + +"@napi-rs/nice-openharmony-arm64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz#1120e457d2cc6b2bc86ef0a697faefe2e194dfce" + integrity sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ== + +"@napi-rs/nice-win32-arm64-msvc@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz#91e4cfecf339b43fa7934f0c8b19d04f4cdd9bc0" + integrity sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA== + +"@napi-rs/nice-win32-ia32-msvc@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz#ed9300bba074d3e3b0a077d6b157f2b4ff70af0e" + integrity sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug== + +"@napi-rs/nice-win32-x64-msvc@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz#8292b82fb46458618ccff5b8130f78974349541e" + integrity sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ== + +"@napi-rs/nice@^1.0.4": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice/-/nice-1.1.1.tgz#c1aacd631ecd4c500c959e3e7cfedd5c73bffe2a" + integrity sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw== + optionalDependencies: + "@napi-rs/nice-android-arm-eabi" "1.1.1" + "@napi-rs/nice-android-arm64" "1.1.1" + "@napi-rs/nice-darwin-arm64" "1.1.1" + "@napi-rs/nice-darwin-x64" "1.1.1" + "@napi-rs/nice-freebsd-x64" "1.1.1" + "@napi-rs/nice-linux-arm-gnueabihf" "1.1.1" + "@napi-rs/nice-linux-arm64-gnu" "1.1.1" + "@napi-rs/nice-linux-arm64-musl" "1.1.1" + "@napi-rs/nice-linux-ppc64-gnu" "1.1.1" + "@napi-rs/nice-linux-riscv64-gnu" "1.1.1" + "@napi-rs/nice-linux-s390x-gnu" "1.1.1" + "@napi-rs/nice-linux-x64-gnu" "1.1.1" + "@napi-rs/nice-linux-x64-musl" "1.1.1" + "@napi-rs/nice-openharmony-arm64" "1.1.1" + "@napi-rs/nice-win32-arm64-msvc" "1.1.1" + "@napi-rs/nice-win32-ia32-msvc" "1.1.1" + "@napi-rs/nice-win32-x64-msvc" "1.1.1" + +"@npmcli/agent@^4.0.0": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-4.0.2.tgz#9e659c2474294cb88bd382fef5d3857dc14fcbf6" + integrity sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg== + dependencies: + agent-base "^7.1.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.1" + lru-cache "^11.2.1" + socks-proxy-agent "^8.0.3" + +"@npmcli/fs@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-5.0.0.tgz#674619771907342b3d1ac197aaf1deeb657e3539" + integrity sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og== + dependencies: + semver "^7.3.5" + +"@npmcli/git@^7.0.0": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-7.0.2.tgz#680c3271fe51401c07ee41076be678851e600ff0" + integrity sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg== + dependencies: + "@gar/promise-retry" "^1.0.0" + "@npmcli/promise-spawn" "^9.0.0" + ini "^6.0.0" + lru-cache "^11.2.1" + npm-pick-manifest "^11.0.1" + proc-log "^6.0.0" + semver "^7.3.5" + which "^6.0.0" + +"@npmcli/installed-package-contents@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz#18e5070704cfe0278f9ae48038558b6efd438426" + integrity sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA== + dependencies: + npm-bundled "^5.0.0" + npm-normalize-package-bin "^5.0.0" + +"@npmcli/node-gyp@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz#35475a58b5d791764a7252231197a14deefe8e47" + integrity sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ== + +"@npmcli/package-json@^7.0.0": + version "7.0.5" + resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-7.0.5.tgz#e29481dfc586d1625a6553799e6bec52ae0487a5" + integrity sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ== + dependencies: + "@npmcli/git" "^7.0.0" + glob "^13.0.0" + hosted-git-info "^9.0.0" + json-parse-even-better-errors "^5.0.0" + proc-log "^6.0.0" + semver "^7.5.3" + spdx-expression-parse "^4.0.0" + +"@npmcli/promise-spawn@^9.0.0": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz#20e80cbdd2f24ad263a15de3ebbb1673cb82005b" + integrity sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q== + dependencies: + which "^6.0.0" + +"@npmcli/redact@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/redact/-/redact-4.0.0.tgz#c91121e02b7559a997614a2c1057cd7fc67608c4" + integrity sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q== + +"@npmcli/run-script@^10.0.0": + version "10.0.4" + resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-10.0.4.tgz#99cddae483ce3dbf1a10f5683a4e6aaa02345ac0" + integrity sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg== + dependencies: + "@npmcli/node-gyp" "^5.0.0" + "@npmcli/package-json" "^7.0.0" + "@npmcli/promise-spawn" "^9.0.0" + node-gyp "^12.1.0" + proc-log "^6.0.0" + +"@parcel/watcher-android-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz#99aaa3223d43807c9340af439cad7e9b6d26ada6" + integrity sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA== + +"@parcel/watcher-darwin-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz#024496e586b4744f09ce532bbe89fe38ef02a64e" + integrity sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw== + +"@parcel/watcher-darwin-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz#a4621df1359a93d39a332d9bab5ff09016a0608f" + integrity sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw== + +"@parcel/watcher-freebsd-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz#7f565ed1a5b3a5e604e6a4799121518265d62a3d" + integrity sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ== + +"@parcel/watcher-linux-arm-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz#ad7d3825e67b81999165da42593022045abc0889" + integrity sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg== + +"@parcel/watcher-linux-arm-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz#fe7d1cccb2c483215c090e938cf5cf404d2f9a8c" + integrity sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw== + +"@parcel/watcher-linux-arm64-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz#7e239dcb4646c4c79f006a7131a48238249530da" + integrity sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g== + +"@parcel/watcher-linux-arm64-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz#c58b8d9c6d8d81594be00dd83aab741c1aaf7e0e" + integrity sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA== + +"@parcel/watcher-linux-x64-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz#5184fa9a770478d86e56875f4ee163a0abdc8791" + integrity sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A== + +"@parcel/watcher-linux-x64-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz#2d1c55aa7246cbc7670e2612058a8a542c9cf246" + integrity sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw== + +"@parcel/watcher-win32-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz#15e09432040fee9e2213aa9c10ed589012526def" + integrity sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ== + +"@parcel/watcher-win32-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz#9bee199a2a4accd557b451ac2c1c793f305ae012" + integrity sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A== + +"@parcel/watcher@^2.4.1": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.6.0.tgz#99661f6220070b76a766aba6b7e313a087a1be4f" + integrity sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w== + dependencies: + detect-libc "^2.0.3" + is-glob "^4.0.3" + node-addon-api "^7.0.0" + picomatch "^4.0.4" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.6.0" + "@parcel/watcher-darwin-arm64" "2.6.0" + "@parcel/watcher-darwin-x64" "2.6.0" + "@parcel/watcher-freebsd-x64" "2.6.0" + "@parcel/watcher-linux-arm-glibc" "2.6.0" + "@parcel/watcher-linux-arm-musl" "2.6.0" + "@parcel/watcher-linux-arm64-glibc" "2.6.0" + "@parcel/watcher-linux-arm64-musl" "2.6.0" + "@parcel/watcher-linux-x64-glibc" "2.6.0" + "@parcel/watcher-linux-x64-musl" "2.6.0" + "@parcel/watcher-win32-arm64" "2.6.0" + "@parcel/watcher-win32-x64" "2.6.0" + +"@rollup/rollup-android-arm-eabi@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz#a6742c74c7d9d6d604ef8a48f99326b4ecda3d82" + integrity sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg== + +"@rollup/rollup-android-arm-eabi@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz#b0ab422fb60f3583c8c789e856d64a32507f299e" + integrity sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw== + +"@rollup/rollup-android-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz#97247be098de4df0c11971089fd2edf80a5da8cf" + integrity sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q== + +"@rollup/rollup-android-arm64@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz#047e967eeb440a299f1abc773579b5c2516fa48d" + integrity sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA== + +"@rollup/rollup-darwin-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz#674852cf14cf11b8056e0b1a2f4e872b523576cf" + integrity sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg== + +"@rollup/rollup-darwin-arm64@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz#18369e67d0d3ffcb01a95561217447b791727697" + integrity sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ== + +"@rollup/rollup-darwin-x64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz#36dfd7ed0aaf4d9d89d9ef983af72632455b0246" + integrity sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w== + +"@rollup/rollup-darwin-x64@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz#bf23ae4c5c0f841b123bc79e3b246176c6d08fd7" + integrity sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g== + +"@rollup/rollup-freebsd-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz#2f87c2074b4220260fdb52a9996246edfc633c22" + integrity sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA== + +"@rollup/rollup-freebsd-arm64@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz#c92a1b07d0243181f26d9deb278c3ef09e01f065" + integrity sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ== + +"@rollup/rollup-freebsd-x64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz#9b5a26522a38a95dc06616d1939d4d9a76937803" + integrity sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg== + +"@rollup/rollup-freebsd-x64@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz#b69da0407ea06497d395be0e799cc601004b372e" + integrity sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw== + +"@rollup/rollup-linux-arm-gnueabihf@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz#86aa4859385a8734235b5e40a48e52d770758c3a" + integrity sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw== + +"@rollup/rollup-linux-arm-gnueabihf@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz#1b4b63c57fc20e6ea4748a8ea864d86513328ec4" + integrity sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA== + +"@rollup/rollup-linux-arm-musleabihf@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz#cbe70e56e6ece8dac83eb773b624fc9e5a460976" + integrity sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA== + +"@rollup/rollup-linux-arm-musleabihf@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz#64334f5a5178cabb15e7d2a91fb592db1f8621d3" + integrity sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA== + +"@rollup/rollup-linux-arm64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz#d14992a2e653bc3263d284bc6579b7a2890e1c45" + integrity sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA== + +"@rollup/rollup-linux-arm64-gnu@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz#1ff299f7825f0f52a8e107dac7f15ce73009848b" + integrity sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q== + +"@rollup/rollup-linux-arm64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz#2fdd1ddc434ea90aeaa0851d2044789b4d07f6da" + integrity sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA== + +"@rollup/rollup-linux-arm64-musl@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz#99052380e7a94fa440b9166c67a909aa3572f089" + integrity sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ== + +"@rollup/rollup-linux-loong64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz#8a181e6f89f969f21666a743cd411416c80099e7" + integrity sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg== + +"@rollup/rollup-linux-loong64-gnu@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz#5f58b576b3668edf62acf0c5265826267b7d858f" + integrity sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ== + +"@rollup/rollup-linux-loong64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz#904125af2babc395f8061daa27b5af1f4e3f2f78" + integrity sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q== + +"@rollup/rollup-linux-loong64-musl@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz#a8cd0337e3ff3a0c95ead67715c51af77c5aff36" + integrity sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw== + +"@rollup/rollup-linux-ppc64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz#a57970ac6864c9a3447411a658224bdcf948be22" + integrity sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA== + +"@rollup/rollup-linux-ppc64-gnu@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz#6335cec15b55a6b063e34cb7e968515fa500ffd4" + integrity sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA== + +"@rollup/rollup-linux-ppc64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz#bb84de5b26870567a4267666e08891e80bb56a63" + integrity sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA== + +"@rollup/rollup-linux-ppc64-musl@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz#cf2b6fd238f0928c5657bb8a5ca7751dfc2e6ce4" + integrity sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw== + +"@rollup/rollup-linux-riscv64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz#72d00d2c7fb375ce3564e759db33f17a35bffab9" + integrity sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg== + +"@rollup/rollup-linux-riscv64-gnu@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz#df9508c8721437f7e51990caaa61d2f38db73cbf" + integrity sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg== + +"@rollup/rollup-linux-riscv64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz#4c166ef58e718f9245bd31873384ba15a5c1a883" + integrity sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg== + +"@rollup/rollup-linux-riscv64-musl@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz#0cfe93072f4c398bb9d666e5953371a22aba7f4a" + integrity sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ== + +"@rollup/rollup-linux-s390x-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz#bb5025cde9a61db478c2ca7215808ad3bce73a09" + integrity sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w== + +"@rollup/rollup-linux-s390x-gnu@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz#f8e7bdf3d419866b688b09d9348253925232d1cb" + integrity sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg== + +"@rollup/rollup-linux-x64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz#9b66b1f9cd95c6624c788f021c756269ffed1552" + integrity sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg== + +"@rollup/rollup-linux-x64-gnu@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz#57ef7f039c4f7de0e913f1f2680385467b86bb15" + integrity sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg== + +"@rollup/rollup-linux-x64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz#b007ca255dc7166017d57d7d2451963f0bd23fd9" + integrity sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg== + +"@rollup/rollup-linux-x64-musl@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz#6f5b5693d4beb152bf518c487d259362dbece449" + integrity sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw== + +"@rollup/rollup-openbsd-x64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz#e8b357b2d1aa2c8d76a98f5f0d889eabe93f4ef9" + integrity sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ== + +"@rollup/rollup-openbsd-x64@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz#9909010093ed7fb2480c73bc193a8f4d44ffb9e3" + integrity sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA== + +"@rollup/rollup-openharmony-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz#96c2e3f4aacd3d921981329831ff8dde492204dc" + integrity sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA== + +"@rollup/rollup-openharmony-arm64@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz#aba90b57725fcf4c4072c95a6dbfbcf7500a770d" + integrity sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg== + +"@rollup/rollup-win32-arm64-msvc@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz#2d865149d706d938df8b4b8f117e69a77646d581" + integrity sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A== + +"@rollup/rollup-win32-arm64-msvc@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz#12cee2b2e6d37dbb83602686812e4bba290c9aa3" + integrity sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA== + +"@rollup/rollup-win32-ia32-msvc@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz#abe1593be0fa92325e9971c8da429c5e05b92c36" + integrity sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA== + +"@rollup/rollup-win32-ia32-msvc@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz#391a0d6f8458a675ad720cccae9bcb9a48109016" + integrity sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ== + +"@rollup/rollup-win32-x64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz#c4af3e9518c9a5cd4b1c163dc81d0ad4d82e7eab" + integrity sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA== + +"@rollup/rollup-win32-x64-gnu@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz#3f38bb180fcf1cfa91975c4b344941e985a6ed13" + integrity sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ== + +"@rollup/rollup-win32-x64-msvc@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz#4584a8a87b29188a4c1fe987a9fcf701e256d86c" + integrity sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== + +"@rollup/rollup-win32-x64-msvc@4.62.3": + version "4.62.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz#0ccafd44a8cbcb33f7feaa9e3e85033e7a52295c" + integrity sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g== + +"@schematics/angular@20.3.32": + version "20.3.32" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.32.tgz#a44f2378af9bd64143ba0619ab3a0fb2cef8779a" + integrity sha512-asporFGNP/U4p/A6jHqRmC8zGBxsSbjA/17I0p1tIW4HLbDTwJ0Z1gTSGpkHGRGTeowPZZSCj7s0IWVoaBCjnA== + dependencies: + "@angular-devkit/core" "20.3.32" + "@angular-devkit/schematics" "20.3.32" + jsonc-parser "3.3.1" + +"@sigstore/bundle@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-4.0.0.tgz#854eda43eb6a59352037e49000177c8904572f83" + integrity sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A== + dependencies: + "@sigstore/protobuf-specs" "^0.5.0" + +"@sigstore/core@^3.2.0", "@sigstore/core@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-3.2.1.tgz#4efd4ab0f59e768b6daf65612024ba925787de72" + integrity sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g== + +"@sigstore/protobuf-specs@^0.5.0": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz#5401e444b6ab0db7d1969c91c43e7954927a52fe" + integrity sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g== + +"@sigstore/sign@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-4.1.1.tgz#34765fe4a190d693340c0771a3d150a397bcfc55" + integrity sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ== + dependencies: + "@gar/promise-retry" "^1.0.2" + "@sigstore/bundle" "^4.0.0" + "@sigstore/core" "^3.2.0" + "@sigstore/protobuf-specs" "^0.5.0" + make-fetch-happen "^15.0.4" + proc-log "^6.1.0" + +"@sigstore/tuf@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-4.0.2.tgz#7d2fa2abcd5afa5baf752671d14a1c6ed0ed3196" + integrity sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ== + dependencies: + "@sigstore/protobuf-specs" "^0.5.0" + tuf-js "^4.1.0" + +"@sigstore/verify@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-3.1.1.tgz#13c1c1cff28fe81f662de29d85ba5ae048fcbd8d" + integrity sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA== + dependencies: + "@sigstore/bundle" "^4.0.0" + "@sigstore/core" "^3.2.1" + "@sigstore/protobuf-specs" "^0.5.0" + +"@tufjs/canonical-json@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz#a52f61a3d7374833fca945b2549bc30a2dd40d0a" + integrity sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA== + +"@tufjs/models@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-4.1.0.tgz#494b39cf5e2f6855d80031246dd236d8086069b3" + integrity sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww== + dependencies: + "@tufjs/canonical-json" "2.0.0" + minimatch "^10.1.1" + +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/estree@1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + +"@vitejs/plugin-basic-ssl@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz#c70d2a922bc437f154089d7ef0505db4b383eb7b" + integrity sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA== + +"@yarnpkg/lockfile@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + +abbrev@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" + integrity sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA== + +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + +agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + +ajv-formats@3.0.1, ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + +ajv@8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ajv@^8.0.0, ajv@^8.17.1: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +algoliasearch@5.35.0: + version "5.35.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.35.0.tgz#ce12d1d287d6f4a80b9998568f806c92dabba566" + integrity sha512-Y+moNhsqgLmvJdgTsO4GZNgsaDWv8AOGAaPeIeHKlDn/XunoAqYbA+XNpBd1dW8GOXAUDyxC9Rxc7AV4kpFcIg== + dependencies: + "@algolia/abtesting" "1.1.0" + "@algolia/client-abtesting" "5.35.0" + "@algolia/client-analytics" "5.35.0" + "@algolia/client-common" "5.35.0" + "@algolia/client-insights" "5.35.0" + "@algolia/client-personalization" "5.35.0" + "@algolia/client-query-suggestions" "5.35.0" + "@algolia/client-search" "5.35.0" + "@algolia/ingestion" "1.35.0" + "@algolia/monitoring" "1.35.0" + "@algolia/recommend" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +ansi-escapes@^7.0.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz#5395bb74b2150a4a1d6e3c2565f4aeca78d28627" + integrity sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== + dependencies: + environment "^1.0.0" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.0.0, ansi-styles@^6.2.1: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +baseline-browser-mapping@^2.11.12: + version "2.11.20" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303" + integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw== + +beasties@0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/beasties/-/beasties-0.3.5.tgz#a52abea4ead105c1aded66b1a01a74e96585dc81" + integrity sha512-NaWu+f4YrJxEttJSm16AzMIFtVldCvaJ68b1L098KpqXmxt9xOLtKoLkKxb8ekhOrLqEJAbvT6n6SEvB/sac7A== + dependencies: + css-select "^6.0.0" + css-what "^7.0.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + htmlparser2 "^10.0.0" + picocolors "^1.1.1" + postcss "^8.4.49" + postcss-media-query-parser "^0.2.3" + +body-parser@^2.2.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" + integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== + dependencies: + bytes "^3.1.2" + content-type "^2.0.0" + debug "^4.4.3" + http-errors "^2.0.1" + iconv-lite "^0.7.2" + on-finished "^2.4.1" + qs "^6.15.2" + raw-body "^3.0.2" + type-is "^2.1.0" + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^5.0.5: + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== + dependencies: + balanced-match "^4.0.2" + +browserslist@^4.23.0, browserslist@^4.24.0: + version "4.28.8" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0" + integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA== + dependencies: + baseline-browser-mapping "^2.11.12" + caniuse-lite "^1.0.30001809" + electron-to-chromium "^1.5.402" + node-releases "^2.0.53" + update-browserslist-db "^1.3.0" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +bytes@^3.1.2, bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cacache@^20.0.0, cacache@^20.0.1: + version "20.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-20.0.4.tgz#9b547dc3db0c1f87cba6dbbff91fb17181b4bbb1" + integrity sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA== + dependencies: + "@npmcli/fs" "^5.0.0" + fs-minipass "^3.0.0" + glob "^13.0.0" + lru-cache "^11.1.0" + minipass "^7.0.3" + minipass-collect "^2.0.1" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + p-map "^7.0.2" + ssri "^13.0.0" + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +caniuse-lite@^1.0.30001809: + version "1.0.30001810" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2" + integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg== + +chalk@^5.3.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + +chardet@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-2.2.0.tgz#005d664f2cbd4961888d2e2c32c5a69e59d8eec4" + integrity sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA== + +chokidar@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +chownr@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4" + integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== + +cli-cursor@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38" + integrity sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw== + dependencies: + restore-cursor "^5.0.0" + +cli-spinners@^2.9.2: + version "2.9.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== + +cli-truncate@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-4.0.0.tgz#6cc28a2924fee9e25ce91e973db56c7066e6172a" + integrity sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA== + dependencies: + slice-ansi "^5.0.0" + string-width "^7.0.0" + +cli-width@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5" + integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== + +cliui@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291" + integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== + dependencies: + string-width "^7.2.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colorette@^2.0.20: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +content-type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" + integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== + +convert-source-map@^1.5.1: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cors@^2.8.5: + version "2.8.6" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + +cross-spawn@^7.0.5: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css-select@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-6.0.0.tgz#7e63f09881ad118084091048ed543786dad96644" + integrity sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw== + dependencies: + boolbase "^1.0.0" + css-what "^7.0.0" + domhandler "^5.0.3" + domutils "^3.2.2" + nth-check "^2.1.1" + +css-what@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-7.0.0.tgz#5796fbebd43571d73c60ba0dd7a6e75dd0d22fe4" + integrity sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ== + +debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.4, debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +depd@^2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +detect-libc@^2.0.1, detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +electron-to-chromium@^1.5.402: + version "1.5.420" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44" + integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA== + +emoji-regex@^10.3.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" + integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +encodeurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + +entities@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-8.0.0.tgz#c1df5fe3602429747fa233d0dd26f142f0ce4743" + integrity sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA== + +env-paths@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +environment@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" + integrity sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +esbuild@0.28.1, "esbuild@^0.27.0 || ^0.28.0": + version "0.28.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" + integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.1" + "@esbuild/android-arm" "0.28.1" + "@esbuild/android-arm64" "0.28.1" + "@esbuild/android-x64" "0.28.1" + "@esbuild/darwin-arm64" "0.28.1" + "@esbuild/darwin-x64" "0.28.1" + "@esbuild/freebsd-arm64" "0.28.1" + "@esbuild/freebsd-x64" "0.28.1" + "@esbuild/linux-arm" "0.28.1" + "@esbuild/linux-arm64" "0.28.1" + "@esbuild/linux-ia32" "0.28.1" + "@esbuild/linux-loong64" "0.28.1" + "@esbuild/linux-mips64el" "0.28.1" + "@esbuild/linux-ppc64" "0.28.1" + "@esbuild/linux-riscv64" "0.28.1" + "@esbuild/linux-s390x" "0.28.1" + "@esbuild/linux-x64" "0.28.1" + "@esbuild/netbsd-arm64" "0.28.1" + "@esbuild/netbsd-x64" "0.28.1" + "@esbuild/openbsd-arm64" "0.28.1" + "@esbuild/openbsd-x64" "0.28.1" + "@esbuild/openharmony-arm64" "0.28.1" + "@esbuild/sunos-x64" "0.28.1" + "@esbuild/win32-arm64" "0.28.1" + "@esbuild/win32-ia32" "0.28.1" + "@esbuild/win32-x64" "0.28.1" + +escalade@^3.1.1, escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +etag@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + +eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz#4e198eb91cd333d0a8ddcc036502b3618a25f449" + integrity sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg== + +eventsource@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" + integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== + dependencies: + eventsource-parser "^3.0.1" + +exponential-backoff@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" + integrity sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA== + +express-rate-limit@^8.2.1: + version "8.6.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.6.1.tgz#e0977ec8075346e207d788383362ada7c08324ae" + integrity sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA== + dependencies: + debug "^4.4.3" + ip-address "^10.2.0" + +express@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.1" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-uri@^3.0.1: + version "3.1.7" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a" + integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg== + +fdir@^6.4.4, fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + +fs-minipass@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54" + integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw== + dependencies: + minipass "^7.0.3" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-east-asian-width@^1.0.0, get-east-asian-width@^1.3.1, get-east-asian-width@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9" + integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== + +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + +glob@^13.0.0: + version "13.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" + integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== + dependencies: + minimatch "^10.2.2" + minipass "^7.1.3" + path-scurry "^2.0.2" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.1.2, graceful-fs@^4.2.6: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +hasown@^2.0.2, hasown@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + +hono@^4.11.4: + version "4.13.1" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.1.tgz#d4a606b792954d07a6215d12c01b7cf59da03cbc" + integrity sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw== + +hosted-git-info@^9.0.0: + version "9.0.3" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-9.0.3.tgz#637b511ce62a28e4261a92b8da0a4d6be3522cd4" + integrity sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg== + dependencies: + lru-cache "^11.1.0" + +htmlparser2@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4" + integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.2.2" + entities "^7.0.1" + +http-cache-semantics@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" + integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== + +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +https-proxy-agent@7.0.6, https-proxy-agent@^7.0.1: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + +iconv-lite@^0.7.0, iconv-lite@^0.7.2, iconv-lite@~0.7.0: + version "0.7.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +ignore-walk@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-8.0.0.tgz#380c173badc3a18c57ff33440753f0052f572b14" + integrity sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A== + dependencies: + minimatch "^10.0.3" + +immutable@^5.0.2: + version "5.1.9" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.9.tgz#ac23c3a01992ab665e14ac9ffff298f28cd74a0c" + integrity sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg== + +inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-5.0.0.tgz#a7a4615339843d9a8ccc2d85c9d81cf93ffbc638" + integrity sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw== + +ini@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-6.0.0.tgz#efc7642b276f6a37d22fdf56ef50889d7146bf30" + integrity sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== + +ip-address@^10.1.1, ip-address@^10.2.0: + version "10.3.1" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.3.1.tgz#929f9629d1724f7e1b7485ce89752f3675336a10" + integrity sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-core-module@^2.16.0: + version "2.16.2" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" + integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== + dependencies: + hasown "^2.0.3" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + +is-fullwidth-code-point@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz#046b2a6d4f6b156b2233d3207d4b5a9783999b98" + integrity sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== + dependencies: + get-east-asian-width "^1.3.1" + +is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-interactive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90" + integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ== + +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + +is-unicode-supported@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" + integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== + +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isexe@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-4.0.0.tgz#48f6576af8e87a18feb796b7ed5e2e5903b43dca" + integrity sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw== + +istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-instrument@6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" + integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + +jose@^6.1.3: + version "6.2.4" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.4.tgz#69de7346761cd04942c659e524d988feb16a4a6e" + integrity sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-parse-even-better-errors@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz#93c89f529f022e5dadc233409324f0167b1e903e" + integrity sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-schema-typed@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz#e98ee7b1899ff4a184534d1f167c288c66bbeff4" + integrity sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +jsonc-parser@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +listr2@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-9.0.1.tgz#3cad12d81d998f8024621d9b35c969dba5da4103" + integrity sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g== + dependencies: + cli-truncate "^4.0.0" + colorette "^2.0.20" + eventemitter3 "^5.0.1" + log-update "^6.1.0" + rfdc "^1.4.1" + wrap-ansi "^9.0.0" + +lmdb@3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/lmdb/-/lmdb-3.4.2.tgz#8d90d12ec9a575052d30262e69b0a02a3d103ceb" + integrity sha512-nwVGUfTBUwJKXd6lRV8pFNfnrCC1+l49ESJRM19t/tFb/97QfJEixe5DYRvug5JO7DSFKoKaVy7oGMt5rVqZvg== + dependencies: + msgpackr "^1.11.2" + node-addon-api "^6.1.0" + node-gyp-build-optional-packages "5.2.2" + ordered-binary "^1.5.3" + weak-lru-cache "^1.2.2" + optionalDependencies: + "@lmdb/lmdb-darwin-arm64" "3.4.2" + "@lmdb/lmdb-darwin-x64" "3.4.2" + "@lmdb/lmdb-linux-arm" "3.4.2" + "@lmdb/lmdb-linux-arm64" "3.4.2" + "@lmdb/lmdb-linux-x64" "3.4.2" + "@lmdb/lmdb-win32-arm64" "3.4.2" + "@lmdb/lmdb-win32-x64" "3.4.2" + +log-symbols@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-6.0.0.tgz#bb95e5f05322651cac30c0feb6404f9f2a8a9439" + integrity sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw== + dependencies: + chalk "^5.3.0" + is-unicode-supported "^1.3.0" + +log-update@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-6.1.0.tgz#1a04ff38166f94647ae1af562f4bd6a15b1b7cd4" + integrity sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w== + dependencies: + ansi-escapes "^7.0.0" + cli-cursor "^5.0.0" + slice-ansi "^7.1.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" + +lru-cache@^11.0.0, lru-cache@^11.1.0, lru-cache@^11.2.1: + version "11.5.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760" + integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +magic-string@0.30.17: + version "0.30.17" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +make-fetch-happen@^15.0.0, make-fetch-happen@^15.0.1, make-fetch-happen@^15.0.4: + version "15.0.6" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz#079dee708c88a04a1f79735bb02789a63597840e" + integrity sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw== + dependencies: + "@gar/promise-retry" "^1.0.0" + "@npmcli/agent" "^4.0.0" + "@npmcli/redact" "^4.0.0" + cacache "^20.0.1" + http-cache-semantics "^4.1.1" + minipass "^7.0.2" + minipass-fetch "^5.0.0" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^1.0.0" + proc-log "^6.0.0" + ssri "^13.0.0" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.1.tgz#6f035400dfe3ab9d5607bc77546ce30cc2f9c6b8" + integrity sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ== + +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + +mimic-function@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" + integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== + +minimatch@^10.0.3, minimatch@^10.1.1, minimatch@^10.2.2: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + +minipass-collect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863" + integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw== + dependencies: + minipass "^7.0.3" + +minipass-fetch@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-5.0.2.tgz#3973a605ddfd8abb865e50d6fc634853c8239729" + integrity sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ== + dependencies: + minipass "^7.0.3" + minipass-sized "^2.0.0" + minizlib "^3.0.1" + optionalDependencies: + iconv-lite "^0.7.2" + +minipass-flush@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.7.tgz#145c383d5ae294b36030aa80d4e872d08bebcb73" + integrity sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA== + dependencies: + minipass "^3.0.0" + +minipass-pipeline@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass-sized@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-2.0.0.tgz#2228ee97e3f74f6b22ba6d1319addb7621534306" + integrity sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA== + dependencies: + minipass "^7.1.2" + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^7.0.2, minipass@^7.0.3, minipass@^7.0.4, minipass@^7.1.2, minipass@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +minizlib@^3.0.1, minizlib@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c" + integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw== + dependencies: + minipass "^7.1.2" + +mrmime@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" + integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +msgpackr-extract@^3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz#d252698947f7a1a62478d22bfb789ba48dd4c1ac" + integrity sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw== + dependencies: + node-gyp-build-optional-packages "5.2.2" + optionalDependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.4" + +msgpackr@^1.11.2: + version "1.12.1" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.12.1.tgz#61d48d6becf4d5c4d659b2c5260240dc2c09bd44" + integrity sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ== + optionalDependencies: + msgpackr-extract "^3.0.2" + +mute-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-2.0.0.tgz#a5446fc0c512b71c83c44d908d5c7b7b4c493b2b" + integrity sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA== + +nanoid@^3.3.16: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + +node-addon-api@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" + integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + +node-gyp-build-optional-packages@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz#522f50c2d53134d7f3a76cd7255de4ab6c96a3a4" + integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== + dependencies: + detect-libc "^2.0.1" + +node-gyp@^12.1.0: + version "12.4.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-12.4.0.tgz#2d017b6ea1ca9294dbbee75be533728f49257024" + integrity sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw== + dependencies: + env-paths "^2.2.0" + exponential-backoff "^3.1.1" + graceful-fs "^4.2.6" + nopt "^9.0.0" + proc-log "^6.0.0" + semver "^7.3.5" + tar "^7.5.4" + tinyglobby "^0.2.12" + undici "^6.25.0" + which "^6.0.0" + +node-releases@^2.0.53: + version "2.0.54" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe" + integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ== + +nopt@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-9.0.0.tgz#6bff0836b2964d24508b6b41b5a9a49c4f4a1f96" + integrity sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw== + dependencies: + abbrev "^4.0.0" + +npm-bundled@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-5.0.0.tgz#5025d847cfd06c7b8d9432df01695d0133d9ee80" + integrity sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw== + dependencies: + npm-normalize-package-bin "^5.0.0" + +npm-install-checks@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-8.0.0.tgz#f5d18e909bb8318d85093e9d8f36ac427c1cbe30" + integrity sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA== + dependencies: + semver "^7.1.1" + +npm-normalize-package-bin@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz#2b207ff260f2e525ddce93356614e2f736728f89" + integrity sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag== + +npm-package-arg@13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-13.0.0.tgz#be6fd7e60c6fd605b85f570e88cace45e2416c8b" + integrity sha512-+t2etZAGcB7TbbLHfDwooV9ppB2LhhcT6A+L9cahsf9mEUAoQ6CktLEVvEnpD0N5CkX7zJqnPGaFtoQDy9EkHQ== + dependencies: + hosted-git-info "^9.0.0" + proc-log "^5.0.0" + semver "^7.3.5" + validate-npm-package-name "^6.0.0" + +npm-package-arg@^13.0.0: + version "13.0.2" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-13.0.2.tgz#72a80f2afe8329860e63854489415e9e9a2f78a7" + integrity sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA== + dependencies: + hosted-git-info "^9.0.0" + proc-log "^6.0.0" + semver "^7.3.5" + validate-npm-package-name "^7.0.0" + +npm-packlist@^10.0.1: + version "10.0.4" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-10.0.4.tgz#aa2e0e4daf910eae8c5745c2645cf8bb8813de01" + integrity sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng== + dependencies: + ignore-walk "^8.0.0" + proc-log "^6.0.0" + +npm-pick-manifest@^11.0.1: + version "11.0.3" + resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz#76cf6593a351849006c36b38a7326798e2a76d13" + integrity sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ== + dependencies: + npm-install-checks "^8.0.0" + npm-normalize-package-bin "^5.0.0" + npm-package-arg "^13.0.0" + semver "^7.3.5" + +npm-registry-fetch@^19.0.0: + version "19.1.1" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz#51e96d21f409a9bc4f96af218a8603e884459024" + integrity sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw== + dependencies: + "@npmcli/redact" "^4.0.0" + jsonparse "^1.3.1" + make-fetch-happen "^15.0.0" + minipass "^7.0.2" + minipass-fetch "^5.0.0" + minizlib "^3.0.1" + npm-package-arg "^13.0.0" + proc-log "^6.0.0" + +nth-check@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-7.0.0.tgz#9f16c92d8c9ef5120e3acd9dd9957cceecc1ab60" + integrity sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== + dependencies: + mimic-function "^5.0.0" + +ora@8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/ora/-/ora-8.2.0.tgz#8fbbb7151afe33b540dd153f171ffa8bd38e9861" + integrity sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw== + dependencies: + chalk "^5.3.0" + cli-cursor "^5.0.0" + cli-spinners "^2.9.2" + is-interactive "^2.0.0" + is-unicode-supported "^2.0.0" + log-symbols "^6.0.0" + stdin-discarder "^0.2.2" + string-width "^7.2.0" + strip-ansi "^7.1.0" + +ordered-binary@^1.5.3: + version "1.6.1" + resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.6.1.tgz#5ac240ea719d6a0e6d4f0485385d3f9cb1cd4432" + integrity sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w== + +p-map@^7.0.2: + version "7.0.6" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-7.0.6.tgz#5b6ee7f1a775faa48599c8d4420f6a39833cd387" + integrity sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg== + +pacote@21.5.1: + version "21.5.1" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-21.5.1.tgz#74ab896fc7b1f00545ecbbbf666a61895cd50d38" + integrity sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg== + dependencies: + "@gar/promise-retry" "^1.0.0" + "@npmcli/git" "^7.0.0" + "@npmcli/installed-package-contents" "^4.0.0" + "@npmcli/package-json" "^7.0.0" + "@npmcli/promise-spawn" "^9.0.0" + "@npmcli/run-script" "^10.0.0" + cacache "^20.0.0" + fs-minipass "^3.0.0" + minipass "^7.0.2" + npm-package-arg "^13.0.0" + npm-packlist "^10.0.1" + npm-pick-manifest "^11.0.1" + npm-registry-fetch "^19.0.0" + proc-log "^6.0.0" + sigstore "^4.0.0" + ssri "^13.0.0" + tar "^7.4.3" + +parse5-html-rewriting-stream@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz#3f442e5b5811a5456e2a56b68ea44ef153b44d92" + integrity sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw== + dependencies: + entities "^6.0.0" + parse5 "^8.0.0" + parse5-sax-parser "^8.0.0" + +parse5-sax-parser@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz#49755efbd2b63846c7b908a297a874af00760715" + integrity sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw== + dependencies: + parse5 "^8.0.0" + +parse5@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-8.0.1.tgz#f43bcd2cd683efe084075333e9ce0da7d06da31e" + integrity sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw== + dependencies: + entities "^8.0.0" + +parseurl@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" + integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + +path-to-regexp@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" + integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== + +piscina@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/piscina/-/piscina-5.2.0.tgz#3a9a568ab9e3a0556b5c94522ba305962de13e3b" + integrity sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA== + optionalDependencies: + "@napi-rs/nice" "^1.0.4" + +pkce-challenge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" + integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== + +postcss-media-query-parser@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244" + integrity sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig== + +postcss@^8.4.49, postcss@^8.5.6: + version "8.5.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594" + integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg== + dependencies: + nanoid "^3.3.16" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +proc-log@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-5.0.0.tgz#e6c93cf37aef33f835c53485f314f50ea906a9d8" + integrity sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ== + +proc-log@^6.0.0, proc-log@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-6.1.0.tgz#18519482a37d5198e231133a70144a50f21f0215" + integrity sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ== + +proxy-addr@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@^6.14.0, qs@^6.15.2: + version "6.15.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" + integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== + dependencies: + es-define-property "^1.0.1" + side-channel "^1.1.1" + +range-parser@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" + integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== + +raw-body@^3.0.0, raw-body@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +reflect-metadata@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve@1.22.10: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +restore-cursor@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-5.1.0.tgz#0766d95699efacb14150993f55baf0953ea1ebe7" + integrity sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA== + dependencies: + onetime "^7.0.0" + signal-exit "^4.1.0" + +rfdc@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + +rollup@4.59.0: + version "4.59.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.59.0.tgz#cf74edac17c1486f562d728a4d923a694abdf06f" + integrity sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.59.0" + "@rollup/rollup-android-arm64" "4.59.0" + "@rollup/rollup-darwin-arm64" "4.59.0" + "@rollup/rollup-darwin-x64" "4.59.0" + "@rollup/rollup-freebsd-arm64" "4.59.0" + "@rollup/rollup-freebsd-x64" "4.59.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.59.0" + "@rollup/rollup-linux-arm-musleabihf" "4.59.0" + "@rollup/rollup-linux-arm64-gnu" "4.59.0" + "@rollup/rollup-linux-arm64-musl" "4.59.0" + "@rollup/rollup-linux-loong64-gnu" "4.59.0" + "@rollup/rollup-linux-loong64-musl" "4.59.0" + "@rollup/rollup-linux-ppc64-gnu" "4.59.0" + "@rollup/rollup-linux-ppc64-musl" "4.59.0" + "@rollup/rollup-linux-riscv64-gnu" "4.59.0" + "@rollup/rollup-linux-riscv64-musl" "4.59.0" + "@rollup/rollup-linux-s390x-gnu" "4.59.0" + "@rollup/rollup-linux-x64-gnu" "4.59.0" + "@rollup/rollup-linux-x64-musl" "4.59.0" + "@rollup/rollup-openbsd-x64" "4.59.0" + "@rollup/rollup-openharmony-arm64" "4.59.0" + "@rollup/rollup-win32-arm64-msvc" "4.59.0" + "@rollup/rollup-win32-ia32-msvc" "4.59.0" + "@rollup/rollup-win32-x64-gnu" "4.59.0" + "@rollup/rollup-win32-x64-msvc" "4.59.0" + fsevents "~2.3.2" + +rollup@^4.43.0: + version "4.62.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.3.tgz#03ee99e2b5b0744dd99be6d4238b2fcd4087b4f6" + integrity sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q== + dependencies: + "@types/estree" "1.0.9" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.62.3" + "@rollup/rollup-android-arm64" "4.62.3" + "@rollup/rollup-darwin-arm64" "4.62.3" + "@rollup/rollup-darwin-x64" "4.62.3" + "@rollup/rollup-freebsd-arm64" "4.62.3" + "@rollup/rollup-freebsd-x64" "4.62.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.62.3" + "@rollup/rollup-linux-arm-musleabihf" "4.62.3" + "@rollup/rollup-linux-arm64-gnu" "4.62.3" + "@rollup/rollup-linux-arm64-musl" "4.62.3" + "@rollup/rollup-linux-loong64-gnu" "4.62.3" + "@rollup/rollup-linux-loong64-musl" "4.62.3" + "@rollup/rollup-linux-ppc64-gnu" "4.62.3" + "@rollup/rollup-linux-ppc64-musl" "4.62.3" + "@rollup/rollup-linux-riscv64-gnu" "4.62.3" + "@rollup/rollup-linux-riscv64-musl" "4.62.3" + "@rollup/rollup-linux-s390x-gnu" "4.62.3" + "@rollup/rollup-linux-x64-gnu" "4.62.3" + "@rollup/rollup-linux-x64-musl" "4.62.3" + "@rollup/rollup-openbsd-x64" "4.62.3" + "@rollup/rollup-openharmony-arm64" "4.62.3" + "@rollup/rollup-win32-arm64-msvc" "4.62.3" + "@rollup/rollup-win32-ia32-msvc" "4.62.3" + "@rollup/rollup-win32-x64-gnu" "4.62.3" + "@rollup/rollup-win32-x64-msvc" "4.62.3" + fsevents "~2.3.2" + +router@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + dependencies: + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + +rxjs@7.8.2, rxjs@~7.8.0: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sass@1.90.0: + version "1.90.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.90.0.tgz#d6fc2be49c7c086ce86ea0b231a35bf9e33cb84b" + integrity sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q== + dependencies: + chokidar "^4.0.0" + immutable "^5.0.2" + source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" + +semver@7.7.2: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.0.0, semver@^7.1.1, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" + +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.2.0" + +setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +sigstore@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-4.1.1.tgz#2959993dbf978c759a5d23d854cbd3729b6dcd97" + integrity sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w== + dependencies: + "@sigstore/bundle" "^4.0.0" + "@sigstore/core" "^3.2.1" + "@sigstore/protobuf-specs" "^0.5.0" + "@sigstore/sign" "^4.1.1" + "@sigstore/tuf" "^4.0.2" + "@sigstore/verify" "^3.1.1" + +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== + dependencies: + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" + +slice-ansi@^7.1.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.2.tgz#adf7be70aa6d72162d907cd0e6d5c11f507b5403" + integrity sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^5.0.0" + +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks-proxy-agent@^8.0.3: + version "8.0.5" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee" + integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== + dependencies: + agent-base "^7.1.2" + debug "^4.3.4" + socks "^2.8.3" + +socks@^2.8.3: + version "2.8.9" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.9.tgz#aa5f130ca0f88a43fa44faf4869c50d22aa27752" + integrity sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw== + dependencies: + ip-address "^10.1.1" + smart-buffer "^4.2.0" + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map-support@0.5.21: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +spdx-exceptions@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz#5d607d27fc806f66d7b64a766650fa890f04ed66" + integrity sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w== + +spdx-expression-parse@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz#a23af9f3132115465dac215c099303e4ceac5794" + integrity sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.23" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz#b069e687b1291a32f126893ed76a27a745ee2133" + integrity sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw== + +ssri@^13.0.0: + version "13.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-13.0.1.tgz#2d8946614d33f4d0c84946bb370dce7a9379fd18" + integrity sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ== + dependencies: + minipass "^7.0.3" + +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +stdin-discarder@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" + integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^7.0.0, string-width@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + +string-width@^8.2.1: + version "8.2.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.2.2.tgz#7310516493df575742fe98af6fae87d85d5ed0ac" + integrity sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg== + dependencies: + get-east-asian-width "^1.5.0" + strip-ansi "^7.1.2" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.1.0, strip-ansi@^7.1.2: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tar@^7.4.3, tar@^7.5.4: + version "7.5.22" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.22.tgz#a696f998136e71487dc3f869a85bba2c67971ba9" + integrity sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA== + dependencies: + "@isaacs/fs-minipass" "^4.0.0" + chownr "^3.0.0" + minipass "^7.1.2" + minizlib "^3.1.0" + yallist "^5.0.0" + +tinyglobby@0.2.14: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + dependencies: + fdir "^6.4.4" + picomatch "^4.0.2" + +tinyglobby@^0.2.12, tinyglobby@^0.2.15: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +tslib@^2.1.0, tslib@^2.3.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tuf-js@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-4.1.0.tgz#ae4ef9afa456fcb4af103dc50a43bc031f066603" + integrity sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ== + dependencies: + "@tufjs/models" "4.1.0" + debug "^4.4.3" + make-fetch-happen "^15.0.1" + +type-is@^2.0.1, type-is@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== + dependencies: + content-type "^2.0.0" + media-typer "^1.1.0" + mime-types "^3.0.0" + +typescript@~5.9.2: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +undici@^6.25.0: + version "6.28.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.28.0.tgz#9f0e385744fef5021d6596c5bccd783f61193c1c" + integrity sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA== + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-browserslist-db@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836" + integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +"upgrade_client_lib@file:../packages/full-browser": + version "6.7.0" + +validate-npm-package-name@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz#4e8d2c4d939975a73dd1b7a65e8f08d44c85df96" + integrity sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ== + +validate-npm-package-name@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz#e57c3d721a4c8bbff454a246e7f7da811559ea0d" + integrity sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A== + +vary@^1, vary@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +vite@7.3.6: + version "7.3.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" + integrity sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg== + dependencies: + esbuild "^0.27.0 || ^0.28.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +watchpack@2.4.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947" + integrity sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + +weak-lru-cache@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz#fdbb6741f36bae9540d12f480ce8254060dccd19" + integrity sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw== + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +which@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/which/-/which-6.0.1.tgz#021642443a198fb93b784a5606721cb18cfcbfce" + integrity sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg== + dependencies: + isexe "^4.0.0" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98" + integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== + dependencies: + ansi-styles "^6.2.1" + string-width "^7.0.0" + strip-ansi "^7.1.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yallist@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" + integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== + +yargs-parser@^22.0.0: + version "22.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" + integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== + +yargs@18.0.0: + version "18.0.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1" + integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg== + dependencies: + cliui "^9.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + string-width "^7.2.0" + y18n "^5.0.5" + yargs-parser "^22.0.0" + +yargs@^18.0.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.1.0.tgz#cd7e98c703ef51695bbbf062ed58f28e94291b56" + integrity sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg== + dependencies: + cliui "^9.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + string-width "^8.2.1" + y18n "^5.0.5" + yargs-parser "^22.0.0" + +yoctocolors-cjs@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz#7e4964ea8ec422b7a40ac917d3a344cfd2304baa" + integrity sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw== + +zod-to-json-schema@^3.25.1: + version "3.25.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa" + integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA== + +zod@4.1.13: + version "4.1.13" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.13.tgz#93699a8afe937ba96badbb0ce8be6033c0a4b6b1" + integrity sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig== + +"zod@^3.25 || ^4.0": + version "4.4.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" + integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== + +zone.js@~0.15.0: + version "0.15.1" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.15.1.tgz#1e109adb75f80e9e004ee8e0d4a0a52e0a336481" + integrity sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w== diff --git a/clientlibs/js/src/ApiService/ApiService.spec.ts b/clientlibs/js/src/ApiService/ApiService.spec.ts index 3d226ea963..a3e34e4f86 100644 --- a/clientlibs/js/src/ApiService/ApiService.spec.ts +++ b/clientlibs/js/src/ApiService/ApiService.spec.ts @@ -32,7 +32,6 @@ const defaultConfig: UpGradeClientInterfaces.IConfig = { clientSessionId: 'testClientSessionId', token: 'testToken', httpClient: mockHttpClient, - featureFlagUserGroupsForSession: null, }; describe('ApiService', () => { @@ -312,6 +311,7 @@ describe('ApiService', () => { status: MARKED_DECISION_POINT_STATUS.CONDITION_APPLIED, }; const expectedRequestBody: UpGradeClientRequests.IMarkDecisionPointRequestBody = { + context: defaultConfig.context, status: params.status, data: { ...mockAssignment, @@ -333,6 +333,7 @@ describe('ApiService', () => { uniquifier: 'unique123', }; const expectedRequestBody: UpGradeClientRequests.IMarkDecisionPointRequestBody = { + context: defaultConfig.context, status: params.status, data: { ...mockAssignment, @@ -355,6 +356,7 @@ describe('ApiService', () => { clientError: 'something went wrong', }; const expectedRequestBody: UpGradeClientRequests.IMarkDecisionPointRequestBody = { + context: defaultConfig.context, status: params.status, data: { ...mockAssignment, @@ -410,74 +412,67 @@ describe('ApiService', () => { }); }); - describe('#setFeatureFlagUserGroupsForSession', () => { - it('should update internal groupsForSession and includeStoredUserGroups properties', () => { - const mockGroupsForSession = { - school: ['testSchool1', 'testSchool2'], - class: ['testClass1'], - }; - const mockIncludeStoredUserGroups = true; - - apiService.setFeatureFlagUserGroupsForSession(mockGroupsForSession, mockIncludeStoredUserGroups); - - // Verify internal state was updated by checking if the values are used in subsequent requests - // Since the properties are private, we'll verify this through their usage in other methods - expect(apiService).toBeDefined(); - // The actual verification happens by checking if these values are used in feature flag requests - }); - - it('should handle null groupsForSession', () => { - const mockIncludeStoredUserGroups = false; + describe('#getAllFeatureFlags', () => { + const expectedUrl = `${defaultConfig.hostURL}/api/${defaultConfig.apiVersion}/featureflag`; + const expectedOptions = { + headers: { + 'Content-Type': 'application/json', + 'Session-Id': 'testClientSessionId', + URL: expectedUrl, + 'User-Id': defaultConfig.userId, + 'Client-Context': defaultConfig.context, + 'Client-Version': CLIENT_VERSION, + Authorization: 'Bearer testToken', + }, + withCredentials: false, + }; - expect(() => { - apiService.setFeatureFlagUserGroupsForSession(null as any, mockIncludeStoredUserGroups); - }).not.toThrow(); - }); + it('sends just context with no arguments', async () => { + mockHttpClient.doPost.mockResolvedValue(['flag1']); - it('should handle undefined groupsForSession', () => { - const mockIncludeStoredUserGroups = false; + const result = await apiService.getAllFeatureFlags(); - expect(() => { - apiService.setFeatureFlagUserGroupsForSession(undefined as any, mockIncludeStoredUserGroups); - }).not.toThrow(); + expect(mockHttpClient.doPost).toHaveBeenCalledWith( + expectedUrl, + { context: defaultConfig.context }, + expectedOptions + ); + expect(result).toEqual(['flag1']); }); - it('should handle empty groupsForSession object', () => { - const mockGroupsForSession = {}; - const mockIncludeStoredUserGroups = true; - - expect(() => { - apiService.setFeatureFlagUserGroupsForSession(mockGroupsForSession, mockIncludeStoredUserGroups); - }).not.toThrow(); - }); + it('sends useSingleGroupSet as given', async () => { + const useSingleGroupSet = { groups: { schoolId: ['school-a'] }, includeStoredUserGroups: false }; + mockHttpClient.doPost.mockResolvedValue(['flag1']); - it('should update includeStoredUserGroups to false', () => { - const mockGroupsForSession = { - school: ['testSchool1'], - }; - const mockIncludeStoredUserGroups = false; + const result = await apiService.getAllFeatureFlags({ useSingleGroupSet }); - expect(() => { - apiService.setFeatureFlagUserGroupsForSession(mockGroupsForSession, mockIncludeStoredUserGroups); - }).not.toThrow(); + expect(mockHttpClient.doPost).toHaveBeenCalledWith( + expectedUrl, + { context: defaultConfig.context, useSingleGroupSet }, + expectedOptions + ); + expect(result).toEqual(['flag1']); }); - it('should allow multiple calls to update the configuration', () => { - const firstGroupsForSession = { - school: ['testSchool1'], - }; - const secondGroupsForSession = { - school: ['testSchool2'], - class: ['testClass1'], + it('sends useMultipleGroupSets as given and returns the object response', async () => { + const useMultipleGroupSets = { + mainGroupset: { groups: { schoolId: ['school-a', 'school-b'] } }, + subGroupsets: [ + { groupsetId: 'school-a', groups: { schoolId: ['school-a'] } }, + { groupsetId: 'school-b', groups: { schoolId: ['school-b'] } }, + ], }; + const mockResponse = { mainGroupset: ['flag1'], subGroupsets: { 'school-a': ['flag1'], 'school-b': [] } }; + mockHttpClient.doPost.mockResolvedValue(mockResponse); - // First call - apiService.setFeatureFlagUserGroupsForSession(firstGroupsForSession, true); + const result = await apiService.getAllFeatureFlags({ useMultipleGroupSets }); - // Second call should overwrite the first - expect(() => { - apiService.setFeatureFlagUserGroupsForSession(secondGroupsForSession, false); - }).not.toThrow(); + expect(mockHttpClient.doPost).toHaveBeenCalledWith( + expectedUrl, + { context: defaultConfig.context, useMultipleGroupSets }, + expectedOptions + ); + expect(result).toEqual(mockResponse); }); }); diff --git a/clientlibs/js/src/ApiService/ApiService.ts b/clientlibs/js/src/ApiService/ApiService.ts index c853bd2b2c..c6512dd5c6 100644 --- a/clientlibs/js/src/ApiService/ApiService.ts +++ b/clientlibs/js/src/ApiService/ApiService.ts @@ -17,8 +17,6 @@ export default class ApiService { private clientSessionId: string; private httpClient: UpGradeClientInterfaces.IHttpClientWrapper; private api: IEndpoints; - private groupsForSession: Record> | null; - private includeStoredUserGroups: boolean; constructor(config: UpGradeClientInterfaces.IConfig, private dataService: DataService) { this.context = config.context; @@ -40,16 +38,6 @@ export default class ApiService { reward: `${this.hostUrl}/api/${this.apiVersion}/reward`, }; this.httpClient = this.setHttpClient(config.httpClient); - this.groupsForSession = config.featureFlagUserGroupsForSession?.groupsForSession ?? null; - this.includeStoredUserGroups = config.featureFlagUserGroupsForSession?.includeStoredUserGroups ?? null; - } - - public setFeatureFlagUserGroupsForSession( - groupsForSession: Record>, - includeStoredUserGroups: boolean - ): void { - this.groupsForSession = groupsForSession; - this.includeStoredUserGroups = includeStoredUserGroups; } private setHttpClient(httpClient: UpGradeClientInterfaces.IHttpClientWrapper) { @@ -299,30 +287,30 @@ export default class ApiService { }); } - public async getAllFeatureFlags(): Promise { - let requestBody: UpGradeClientRequests.IGetAllFeatureFlagsRequestBody; - - if (this.groupsForSession && this.includeStoredUserGroups !== undefined) { - // if groupsForSession is provided, we need to include it in the request body - requestBody = { - context: this.context, - groupsForSession: this.groupsForSession, - includeStoredUserGroups: this.includeStoredUserGroups, - }; - } else { - // if no groupsForSession is provided, just use the context - requestBody = { - context: this.context, - }; - } + /** + * Evaluates feature flags for exactly one of `useSingleGroupSet` (response: flat `string[]`) or + * `useMultipleGroupSets` (response: `{ mainGroupset?: string[]; subGroupsets: Record }`). + * Omit both for the standard stored-user lookup (also a flat `string[]`). + */ + public async getAllFeatureFlags( + request: { + useSingleGroupSet?: UpGradeClientInterfaces.ISingleGroupSetOptions; + useMultipleGroupSets?: UpGradeClientInterfaces.IMultipleGroupSetsOptions; + } = {} + ): Promise { + const requestBody: UpGradeClientRequests.IGetAllFeatureFlagsRequestBody = { + context: this.context, + ...request, + } as UpGradeClientRequests.IGetAllFeatureFlagsRequestBody; - const response = await this.sendRequest({ + return this.sendRequest< + string[] | UpGradeClientInterfaces.IMultiGroupSetFeatureFlagsResult, + UpGradeClientRequests.IGetAllFeatureFlagsRequestBody + >({ path: this.api.getAllFeatureFlag, method: UpGradeClientEnums.REQUEST_METHOD.POST, body: requestBody, }); - - return response; } public sendReward(params: { diff --git a/clientlibs/js/src/DataService/DataService.ts b/clientlibs/js/src/DataService/DataService.ts index b8bc0edeca..03843096ba 100644 --- a/clientlibs/js/src/DataService/DataService.ts +++ b/clientlibs/js/src/DataService/DataService.ts @@ -1,6 +1,15 @@ import { UpGradeClientInterfaces } from '../types'; import { IExperimentAssignment } from 'upgrade_types'; +/** The groupset id used for the default (no groups configured / useSingleGroupSet default) case. */ +export const DEFAULT_GROUPSET_ID = '*'; + +/** The group definition remembered per groupset id, so a cache-miss can rehydrate on demand. */ +export interface IGroupsetDefinition { + groups?: Record; + includeStoredUserGroups?: boolean; +} + /** * Synchronous data store */ @@ -8,7 +17,14 @@ export class DataService { private group: UpGradeClientInterfaces.IExperimentUserGroup = null; private workingGroup: UpGradeClientInterfaces.IExperimentUserWorkingGroup = null; private experimentAssignmentData: IExperimentAssignment[] = null; - private featureFlags: string[] = null; + + // All feature flags are stored here, uniformly, keyed by groupset id — the useSingleGroupSet + // default lives under DEFAULT_GROUPSET_ID, mainGroupset/subGroupsets under their own ids. + private featureFlagsByGroupsetId: Map = new Map(); + + // Remembers the groups/includeStoredUserGroups that produced each groupset id, for every id ever + // configured or requested — lets hasFeatureFlag(key, id) rehydrate a cache-miss on demand. + private groupsetDefinitionsById: Map = new Map(); getGroup(): UpGradeClientInterfaces.IExperimentUserGroup { return this.group; @@ -34,16 +50,54 @@ export class DataService { this.experimentAssignmentData = experimentAssignmentData; } + /** @deprecated Use `getFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID)` instead. */ getFeatureFlags(): string[] { - return this.featureFlags; + return this.getFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID); } + /** @deprecated Use `setFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID, ...)` instead. */ setFeatureFlags(featureFlags: string[]) { - this.featureFlags = featureFlags; + this.setFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID, featureFlags); } + /** @deprecated Use `clearFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID)` instead. */ clearFeatureFlags() { - this.featureFlags = null; + this.clearFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID); + } + + /** Returns the flags cached for a groupset id, or null if that id hasn't been fetched yet. */ + getFeatureFlagsForGroupset(groupsetId: string): string[] | null { + return this.featureFlagsByGroupsetId.get(groupsetId) ?? null; + } + + /** Upserts (replaces or adds) the flags cached for exactly this groupset id. */ + setFeatureFlagsForGroupset(groupsetId: string, featureFlags: string[]) { + this.featureFlagsByGroupsetId.set(groupsetId, featureFlags); + } + + /** Bulk-upserts multiple groupsets' flags at once, e.g. from a getAllFeatureFlags response. */ + setFeatureFlagsForGroupsets(featureFlagsByGroupsetId: Record) { + Object.entries(featureFlagsByGroupsetId).forEach(([groupsetId, featureFlags]) => { + this.setFeatureFlagsForGroupset(groupsetId, featureFlags); + }); + } + + clearFeatureFlagsForGroupset(groupsetId: string) { + this.featureFlagsByGroupsetId.delete(groupsetId); + } + + hasFeatureFlagForGroupset(key: string, groupsetId: string): boolean { + return this.featureFlagsByGroupsetId.get(groupsetId)?.includes(key) ?? false; + } + + /** Registers/updates the group definition that produced a groupset id, for later rehydration. */ + registerGroupsetDefinition(groupsetId: string, definition: IGroupsetDefinition) { + this.groupsetDefinitionsById.set(groupsetId, definition); + } + + /** Returns the group definition registered for a groupset id, or null if it was never seen. */ + getGroupsetDefinition(groupsetId: string): IGroupsetDefinition | null { + return this.groupsetDefinitionsById.get(groupsetId) ?? null; } public rotateAssignmentList(assignment: IExperimentAssignment) { @@ -92,12 +146,8 @@ export class DataService { return assignment || emptyAssignment; } + /** @deprecated Use `hasFeatureFlagForGroupset(key, DEFAULT_GROUPSET_ID)` instead. */ public hasFeatureFlag(key: string): boolean { - if (this.featureFlags) { - const result = this.featureFlags.find((data) => data === key); - return !!result; - } else { - return false; - } + return this.hasFeatureFlagForGroupset(key, DEFAULT_GROUPSET_ID); } } diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts index 7afd932a6e..3e6cff65d5 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts @@ -9,6 +9,14 @@ const mockHttpClient = { doPatch: jest.fn(), }; +// jest.spyOn on a method that's already a mock (from a prior test) just returns that same mock, +// call-history and all, rather than resetting it — so each test gets a genuinely fresh jest.fn(). +function mockApiServiceGetAllFeatureFlags(): jest.Mock { + const mockFn = jest.fn(); + ApiService.prototype.getAllFeatureFlags = mockFn; + return mockFn; +} + describe('UpgradeClient', () => { let upgradeClient: UpgradeClient; beforeEach(() => { @@ -121,94 +129,6 @@ describe('UpgradeClient', () => { }); }); - describe('#setFeatureFlagUserGroupsForSession', () => { - it('should call apiService "setFeatureFlagUserGroupsForSession" with valid feature flag options', () => { - const mockFeatureFlagOptions = { - groupsForSession: { - school: ['testSchool1', 'testSchool2'], - class: ['testClass1'], - }, - includeStoredUserGroups: true, - }; - ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); - - upgradeClient.setFeatureFlagUserGroupsForSession(mockFeatureFlagOptions); - - expect(ApiService.prototype.setFeatureFlagUserGroupsForSession).toHaveBeenCalledWith( - mockFeatureFlagOptions.groupsForSession, - mockFeatureFlagOptions.includeStoredUserGroups - ); - }); - - it('should call apiService "setFeatureFlagUserGroupsForSession" with null options', () => { - ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); - - upgradeClient.setFeatureFlagUserGroupsForSession(null); - - expect(ApiService.prototype.setFeatureFlagUserGroupsForSession).toHaveBeenCalledWith(undefined, undefined); - }); - - it('should call apiService "setFeatureFlagUserGroupsForSession" with undefined options', () => { - ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); - - upgradeClient.setFeatureFlagUserGroupsForSession(undefined); - - expect(ApiService.prototype.setFeatureFlagUserGroupsForSession).toHaveBeenCalledWith(undefined, undefined); - }); - - it('should clear the cached feature flags', () => { - ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); - const clearFeatureFlags = jest.spyOn(DataService.prototype, 'clearFeatureFlags'); - - upgradeClient.setFeatureFlagUserGroupsForSession({ - groupsForSession: { classId: ['classB'] }, - includeStoredUserGroups: false, - }); - - expect(clearFeatureFlags).toHaveBeenCalled(); - clearFeatureFlags.mockRestore(); - }); - - it('should not clear the cached feature flags when the options are invalid', () => { - ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); - const clearFeatureFlags = jest.spyOn(DataService.prototype, 'clearFeatureFlags'); - - expect(() => { - upgradeClient.setFeatureFlagUserGroupsForSession({ groupsForSession: null, includeStoredUserGroups: false }); - }).toThrow(); - - expect(clearFeatureFlags).not.toHaveBeenCalled(); - clearFeatureFlags.mockRestore(); - }); - - it('should cause the next getAllFeatureFlags call to refetch against the new groups', async () => { - // the real DataService cache is left unmocked here, since invalidating it is the behavior under test - ApiService.prototype.setFeatureFlagUserGroupsForSession = jest.fn(); - const getAllFeatureFlags = jest - .spyOn(ApiService.prototype, 'getAllFeatureFlags') - .mockResolvedValue(['classAFlag']); - - // baseline: the first call fetches and caches, the second is served from the cache - expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classAFlag']); - expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classAFlag']); - expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); - - getAllFeatureFlags.mockResolvedValue(['classBFlag']); - upgradeClient.setFeatureFlagUserGroupsForSession({ - groupsForSession: { classId: ['classB'] }, - includeStoredUserGroups: false, - }); - - // the cleared cache forces exactly one refetch, whose result becomes the new cached value - expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classBFlag']); - expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); - expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classBFlag']); - expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); - - getAllFeatureFlags.mockRestore(); - }); - }); - describe('#getAllExperimentConditions', () => { it('should call apiService "getAllExperimentConditions" with no options', async () => { ApiService.prototype.getAllExperimentConditions = jest.fn(); @@ -266,35 +186,6 @@ describe('UpgradeClient', () => { }); }); - describe('#getAllFeatureFlags', () => { - it('should call apiService "getAllFeatureFlags" with no options', async () => { - ApiService.prototype.getAllFeatureFlags = jest.fn(); - DataService.prototype.getFeatureFlags = jest.fn((): any => { - return null; - }); - await upgradeClient.getAllFeatureFlags(); - expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalled(); - }); - it('should not call apiService "getAllFeatureFlags" when there is cached data', async () => { - ApiService.prototype.getAllFeatureFlags = jest.fn(); - DataService.prototype.getFeatureFlags = jest.fn((): any => { - return ['foo']; - }); - upgradeClient = new UpgradeClient('1234', 'test.com', 'testContext', { httpClient: mockHttpClient }); - await upgradeClient.getAllFeatureFlags(); - expect(ApiService.prototype.getAllFeatureFlags).not.toHaveBeenCalled(); - }); - - it('should call apiService "getAllFeatureFlags" when there is cached data if ignoreCache is specified', async () => { - ApiService.prototype.getAllFeatureFlags = jest.fn(); - DataService.prototype.getFeatureFlags = jest.fn((): any => { - return ['foo']; - }); - upgradeClient = new UpgradeClient('1234', 'test.com', 'testContext', { httpClient: mockHttpClient }); - await upgradeClient.getAllFeatureFlags({ ignoreCache: true }); - expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalled(); - }); - }); describe('#markDecisionPoint', () => { beforeEach(() => { ApiService.prototype.markDecisionPoint = jest.fn().mockResolvedValue({}); @@ -354,67 +245,398 @@ describe('UpgradeClient', () => { }); }); + describe('feature flag group options', () => { + describe('constructor', () => { + it('accepts useSingleGroupSet via featureFlagGroupOptions', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); + + const client = new UpgradeClient('1234', 'test.com', 'testContext', { + httpClient: mockHttpClient, + featureFlagGroupOptions: { useSingleGroupSet: { groups: { classId: ['classA'] } } }, + }); + + expect(await client.getAllFeatureFlags()).toEqual(['flag1']); + }); + + it('accepts useMultipleGroupSets via featureFlagGroupOptions', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue({ + mainGroupset: ['flag1'], + subGroupsets: { sectionA: ['flag1'] }, + }); + + const client = new UpgradeClient('1234', 'test.com', 'testContext', { + httpClient: mockHttpClient, + featureFlagGroupOptions: { + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + }, + }); + + expect(await client.getAllFeatureFlags()).toEqual({ + mainGroupset: ['flag1'], + subGroupsets: { sectionA: ['flag1'] }, + }); + }); + + it('still accepts the deprecated featureFlagUserGroupsForSession constructor option', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); + + const client = new UpgradeClient('1234', 'test.com', 'testContext', { + httpClient: mockHttpClient, + featureFlagUserGroupsForSession: { + groupsForSession: { classId: ['classA'] }, + includeStoredUserGroups: false, + }, + }); + + expect(await client.getAllFeatureFlags()).toEqual(['flag1']); + }); + + it('throws when the constructor option provides both useSingleGroupSet and useMultipleGroupSets', () => { + expect(() => { + // eslint-disable-next-line no-new + new UpgradeClient('1234', 'test.com', 'testContext', { + httpClient: mockHttpClient, + featureFlagGroupOptions: { + useSingleGroupSet: { groups: { classId: ['classA'] } }, + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'a', groups: { classId: ['classA'] } }] }, + }, + }); + }).toThrow(/must provide either useSingleGroupSet or useMultipleGroupSets, not both/); + }); + }); + + describe('#setFeatureFlagGroupOptions', () => { + it('accepts useSingleGroupSet, includeStoredUserGroups optional', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); + + upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['classA'] } } }); + const result = await upgradeClient.getAllFeatureFlags(); + + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({ + useSingleGroupSet: { groups: { classId: ['classA'] }, includeStoredUserGroups: undefined }, + }); + expect(result).toEqual(['flag1']); + }); + + it('accepts useMultipleGroupSets with no mainGroupset', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue({ + mainGroupset: undefined, + subGroupsets: { sectionA: ['flag1'] }, + }); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }] }, + }); + const result = await upgradeClient.getAllFeatureFlags(); + + expect(result).toEqual({ subGroupsets: { sectionA: ['flag1'] } }); + }); + + it('resetting to null restores standard stored-user lookup', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); + + upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['classA'] } } }); + upgradeClient.setFeatureFlagGroupOptions(null); + await upgradeClient.getAllFeatureFlags(); + + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({ + useSingleGroupSet: { groups: undefined, includeStoredUserGroups: undefined }, + }); + }); + + it('reconfiguring with different groups naturally forces a refetch (content-addressed ids)', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValueOnce(['classAFlag']); + + upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['classA'] } } }); + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classAFlag']); + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classAFlag']); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); + + getAllFeatureFlags.mockResolvedValueOnce(['classBFlag']); + upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['classB'] } } }); + + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classBFlag']); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['classBFlag']); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + }); + + it('throws when both useSingleGroupSet and useMultipleGroupSets are provided', () => { + expect(() => { + upgradeClient.setFeatureFlagGroupOptions({ + useSingleGroupSet: { groups: { classId: ['classA'] } }, + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'a', groups: { classId: ['classA'] } }] }, + }); + }).toThrow(/must provide either useSingleGroupSet or useMultipleGroupSets, not both/); + }); + + it('throws when useMultipleGroupSets.subGroupsets is empty', () => { + expect(() => { + upgradeClient.setFeatureFlagGroupOptions({ useMultipleGroupSets: { subGroupsets: [] } }); + }).toThrow(/subGroupsets must contain at least one entry/); + }); + + it('throws when a subGroupsets entry is missing groupsetId', () => { + expect(() => { + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { subGroupsets: [{ groups: { classId: ['a'] } } as any] }, + }); + }).toThrow(/requires a groupsetId/); + }); + + it('throws when useSingleGroupSet is missing groups', () => { + expect(() => { + upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: {} as any }); + }).toThrow(/groups is required/); + }); + }); + + describe('#setFeatureFlagUserGroupsForSession (deprecated alias)', () => { + it('delegates to setFeatureFlagGroupOptions', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); + + upgradeClient.setFeatureFlagUserGroupsForSession({ + groupsForSession: { classId: ['classA'] }, + includeStoredUserGroups: false, + }); + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['flag1']); + }); + + it('throws error with proper message format when groupsForSession is missing', () => { + expect(() => { + upgradeClient.setFeatureFlagUserGroupsForSession({ includeStoredUserGroups: true } as any); + }).toThrow( + /featureFlagUserGroupsForSession must contain both groupsForSession and includeStoredUserGroups properties/ + ); + }); + + it('throws when includeStoredUserGroups is missing', () => { + expect(() => { + upgradeClient.setFeatureFlagUserGroupsForSession({ groupsForSession: { school: ['a'] } } as any); + }).toThrow( + /featureFlagUserGroupsForSession must contain both groupsForSession and includeStoredUserGroups properties/ + ); + }); + + it('accepts null to reset to default', () => { + expect(() => { + upgradeClient.setFeatureFlagUserGroupsForSession(null); + }).not.toThrow(); + }); + }); + }); + + describe('#getAllFeatureFlags', () => { + it('should call apiService with the default groupset when no config is set', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['foo']); + + const result = await upgradeClient.getAllFeatureFlags(); + + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({ + useSingleGroupSet: { groups: undefined, includeStoredUserGroups: undefined }, + }); + expect(result).toEqual(['foo']); + }); + + it('should not call apiService again when there is cached data', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValue(['foo']); + + await upgradeClient.getAllFeatureFlags(); + await upgradeClient.getAllFeatureFlags(); + + expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); + }); + + it('should call apiService again when ignoreCache is specified', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValue(['foo']); + + await upgradeClient.getAllFeatureFlags(); + await upgradeClient.getAllFeatureFlags({ ignoreCache: true }); + + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + }); + + it('accepts an ad-hoc useSingleGroupSet override for a single call, still returning a flat array', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); + + const result = await upgradeClient.getAllFeatureFlags({ + useSingleGroupSet: { groups: { schoolId: ['school-a'] } }, + }); + + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({ + useSingleGroupSet: { groups: { schoolId: ['school-a'] }, includeStoredUserGroups: undefined }, + }); + expect(result).toEqual(['flag1']); + }); + + it('accepts an ad-hoc useMultipleGroupSets override, one combined request for main + subs', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValue({ + mainGroupset: ['flag1'], + subGroupsets: { sectionA: ['flag1'], sectionB: [] }, + }); + + const result = await upgradeClient.getAllFeatureFlags({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a', 'b'] } }, + subGroupsets: [ + { groupsetId: 'sectionA', groups: { schoolId: ['a'] } }, + { groupsetId: 'sectionB', groups: { schoolId: ['b'] } }, + ], + }, + }); + + expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); + expect(result).toEqual({ mainGroupset: ['flag1'], subGroupsets: { sectionA: ['flag1'], sectionB: [] } }); + }); + + it('only fetches the ids missing from cache, aggregated into one request', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags() + .mockResolvedValueOnce({ mainGroupset: ['flag1'], subGroupsets: { sectionA: ['flag1'] } }) + .mockResolvedValueOnce({ subGroupsets: { sectionB: ['flag2'] } }); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + }); + expect(await upgradeClient.getAllFeatureFlags()).toEqual({ + mainGroupset: ['flag1'], + subGroupsets: { sectionA: ['flag1'] }, + }); + + // reconfigure to include the already-cached mainGroupset + sectionA, plus a new sectionB + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [ + { groupsetId: 'sectionA', groups: { schoolId: ['a'] } }, + { groupsetId: 'sectionB', groups: { schoolId: ['b'] } }, + ], + }, + }); + const result = await upgradeClient.getAllFeatureFlags(); + + expect(result).toEqual({ mainGroupset: ['flag1'], subGroupsets: { sectionA: ['flag1'], sectionB: ['flag2'] } }); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + expect(getAllFeatureFlags).toHaveBeenNthCalledWith(2, { + useMultipleGroupSets: { + subGroupsets: [{ groupsetId: 'sectionB', groups: { schoolId: ['b'] }, includeStoredUserGroups: undefined }], + }, + }); + }); + + it('uses the plain useSingleGroupSet shape when only the mainGroupset needs (re)fetching', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags() + .mockResolvedValueOnce({ mainGroupset: ['flag1'], subGroupsets: { sectionA: ['flag1'] } }) + .mockResolvedValueOnce(['flag1', 'flag2']); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + }); + await upgradeClient.getAllFeatureFlags(); + + // Force a refetch of only the mainGroupset via an ad-hoc single override — sectionA stays cached. + await upgradeClient.getAllFeatureFlags({ + useSingleGroupSet: { groups: { schoolId: ['a'] } }, + ignoreCache: true, + }); + + expect(getAllFeatureFlags).toHaveBeenNthCalledWith(2, { + useSingleGroupSet: { groups: { schoolId: ['a'] }, includeStoredUserGroups: undefined }, + }); + }); + }); + describe('#hasFeatureFlag', () => { it('should call apiService "getAllFeatureFlags" with no options', async () => { - ApiService.prototype.getAllFeatureFlags = jest.fn(); - DataService.prototype.getFeatureFlags = jest.fn((): any => { - return null; - }); + mockApiServiceGetAllFeatureFlags().mockResolvedValue([]); + await upgradeClient.hasFeatureFlag('testFlag'); + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalled(); }); - it('should not call apiService "getAllFeatureFlags" when there is cached data', async () => { - ApiService.prototype.getAllFeatureFlags = jest.fn(); - DataService.prototype.getFeatureFlags = jest.fn((): any => { - return ['foo']; + + it('should not call apiService again when there is cached data', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValue(['testFlag']); + + expect(await upgradeClient.hasFeatureFlag('testFlag')).toBe(true); + expect(await upgradeClient.hasFeatureFlag('testFlag')).toBe(true); + + expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); + }); + + it('resolves to mainGroupset when useMultipleGroupSets has one configured', async () => { + // hasFeatureFlag's rehydration always uses the plain single-groupset shape, regardless of + // whether the id being fetched plays a "main" or "sub" role — so the mock response here is + // a flat array, not the { mainGroupset, subGroupsets } object getAllFeatureFlags() returns. + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a', 'b'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, }); - upgradeClient = new UpgradeClient('1234', 'test.com', 'testContext', { httpClient: mockHttpClient }); - await upgradeClient.hasFeatureFlag('testFlag'); - expect(ApiService.prototype.getAllFeatureFlags).not.toHaveBeenCalled(); + + expect(await upgradeClient.hasFeatureFlag('flag1')).toBe(true); }); - }); - it('should throw error when groupsForSession is missing', () => { - const invalidOptions = { - includeStoredUserGroups: true, - } as any; + it('throws when useMultipleGroupSets has no mainGroupset and no id is given', async () => { + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }] }, + }); - expect(() => { - upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); - }).toThrow(); - }); + await expect(upgradeClient.hasFeatureFlag('testFlag')).rejects.toThrow( + /requires a groupsetId when useMultipleGroupSets has no mainGroupset configured/ + ); + }); - it('should throw error when includeStoredUserGroups is missing', () => { - const invalidOptions = { - groupsForSession: { - school: ['testSchool1'], - }, - } as any; + it('reads a cached subGroupset by id without refetching', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValueOnce({ + subGroupsets: { sectionA: ['flag1'] }, + }); - expect(() => { - upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); - }).toThrow(); - }); + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }] }, + }); + await upgradeClient.getAllFeatureFlags(); - it('should throw error when both properties are missing', () => { - const invalidOptions = {} as any; + expect(await upgradeClient.hasFeatureFlag('flag1', 'sectionA')).toBe(true); + expect(await upgradeClient.hasFeatureFlag('missing', 'sectionA')).toBe(false); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); + }); - expect(() => { - upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); - }).toThrow(); - }); + it('rehydrates a registered-but-uncached groupset id by fetching just that one', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValueOnce(['flag1']); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + subGroupsets: [ + { groupsetId: 'sectionA', groups: { schoolId: ['a'] } }, + { groupsetId: 'sectionB', groups: { schoolId: ['b'] } }, + ], + }, + }); - it('should throw error with proper message format', () => { - const invalidOptions = { - groupsForSession: { - school: ['testSchool1'], - }, - } as any; - - expect(() => { - upgradeClient.setFeatureFlagUserGroupsForSession(invalidOptions); - }).toThrow( - /featureFlagUserGroupsForSession must contain both groupsForSession and includeStoredUserGroups properties/ - ); + // only ask about sectionA — sectionB is registered but never fetched + expect(await upgradeClient.hasFeatureFlag('flag1', 'sectionA')).toBe(true); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); + expect(getAllFeatureFlags).toHaveBeenCalledWith({ + useSingleGroupSet: { groups: { schoolId: ['a'] }, includeStoredUserGroups: undefined }, + }); + }); + + it('throws for a groupset id that was never configured or requested', async () => { + await expect(upgradeClient.hasFeatureFlag('testFlag', 'neverSeen')).rejects.toThrow( + /No feature flags have been fetched or configured for groupset id "neverSeen"/ + ); + }); }); }); diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index 2bffb52fea..faba7f1d1e 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -9,7 +9,12 @@ import { } from 'upgrade_types'; import Assignment from '../Assignment/Assignment'; import ApiService from '../ApiService/ApiService'; -import { DataService } from '../DataService/DataService'; +import { DataService, DEFAULT_GROUPSET_ID, IGroupsetDefinition } from '../DataService/DataService'; + +/** Which groupset `hasFeatureFlag(key)` (no id) resolves to, and what else is configured alongside it. */ +type ActiveGroupConfig = + | { kind: 'single'; groupsetId: string } + | { kind: 'multiple'; mainGroupsetId: string | null; subGroupsetIds: string[] }; declare const API_VERSION: string; @@ -93,54 +98,64 @@ export default class UpgradeClient { * const options: { * token: "someToken"; * clientSessionId: "someSessionId"; - * featureFlagUserGroupsForSession: null + * featureFlagGroupOptions: null * } * * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context); * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context, options); * ``` * - * UPDATE: #featureFlagUserGroupsForSession + * `featureFlagGroupOptions` configures how `/v6/featureflag` requests are evaluated. Provide + * exactly one of: * - * ```typescript - * // required - * const hostUrl: "htts://my-hosted-upgrade-api.com"; - * const userId: "abc123"; - * const context: "my-app-context-name"; + * - `useSingleGroupSet` — the classic one-groupset approach. `getAllFeatureFlags()` returns a + * flat `string[]`, `hasFeatureFlag(key)` needs no id. + * - `useMultipleGroupSets` — an optional `mainGroupset` plus one or more named `subGroupsets`, + * all fetched together in one request. `getAllFeatureFlags()` returns + * `{ mainGroupset?: string[]; subGroupsets: Record }`. `hasFeatureFlag(key)` + * (no id) resolves to `mainGroupset` if one was configured, and throws otherwise — pass a + * `groupsetId` to check a specific named subGroupset instead. * - * // to configure feature flag endpoint to rely on session-only groups or merge supplemental groups with stored user groups - * // see below for usage scenarios - * // note: this is optional, and if not provided, the client will use standard user lookup with stored groups only - * const options: { - * featureFlagUserGroupsForSession: { - * groupsForSession: { "classId": ["testClass"] }; - * includeStoredUserGroups: false; // true to merge with stored user groups, false to skip any stored user entirely - * } - * } + * Omit `featureFlagGroupOptions` entirely for the default: standard stored-user lookup, single + * groupset. * - * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context); - * const upgradeClient: UpgradeClient[] = new UpgradeClient(hostURL, userId, context, options); + * ```typescript + * const options: UpGradeClientInterfaces.IConfigOptions = { + * featureFlagGroupOptions: { + * useSingleGroupSet: { + * groups: { classId: ['testClass'] }, + * includeStoredUserGroups: false, // optional — omitting it is the same as false + * }, + * }, + * }; + * + * const upgradeClient = new UpgradeClient(hostURL, userId, context, options); * ``` * * **Stored-user Mode** (Standard stored user lookup): - * - Omit both `groupsForSession` and `includeStoredUserGroups` parameters + * - Omit `useSingleGroupSet`/`useMultipleGroupSets` (and the deprecated `groupsForSession`) * - Uses only stored user groups from the database * - User must already have been initialized, will 404 if user does not exist * - * **Ephemeral Mode** (Session-only groups): - * - Set `includeStoredUserGroups` to `false` and provide `groupsForSession` - * - Uses only the groups provided in the session, ignoring any stored user groups. + * **Ephemeral Mode** (caller-provided groups only): + * - Provide `groups`; `includeStoredUserGroups` is optional and defaults to `false` + * - Uses only the provided groups, ignoring any stored user groups * - Does not require the user to be initialized (it will bypass stored user lookup) - * - Useful when complete group information is always provided at runtime. - * - * **Merged Mode** (Stored + Session groups): - * - Set `includeStoredUserGroups` to `true` and provide `groupsForSession` - * - User must already have been initialized, will 404 if user does not exist. - * - Session groups are merged with stored groups if they don't already exist for stored user. - * - Session groups are never persisted. - * - Useful for adding context-specific ephemeral groups to an existing user. + * + * **Merged Mode** (stored + caller-provided groups): + * - Provide `groups` and `includeStoredUserGroups: true` (always explicit — never inferred) + * - User must already have been initialized, will 404 if user does not exist + * - Provided groups are merged with stored groups; never persisted + * + * Note: `featureFlagUserGroupsForSession` (with `groupsForSession`/`includeStoredUserGroups`) is + * a deprecated, singular-only synonym for `featureFlagGroupOptions.useSingleGroupSet` — still + * supported, but the new shape is preferred going forward. */ + // Which groupset(s) getAllFeatureFlags()/hasFeatureFlag() use when called with no arguments. + // Defaults to the standard stored-user lookup, single groupset. + private activeConfig: ActiveGroupConfig = { kind: 'single', groupsetId: DEFAULT_GROUPSET_ID }; + constructor(userId: string, hostUrl: string, context: string, options?: UpGradeClientInterfaces.IConfigOptions) { const config: UpGradeClientInterfaces.IConfig = { apiVersion: 'v' + API_VERSION, @@ -150,74 +165,178 @@ export default class UpgradeClient { clientSessionId: options?.clientSessionId || generateUUID(), token: options?.token, httpClient: options?.httpClient, - featureFlagUserGroupsForSession: options?.featureFlagUserGroupsForSession ?? null, }; this.dataService = new DataService(); this.apiService = new ApiService(config, this.dataService); - this.validateFeatureFlagGroupOptions(config.featureFlagUserGroupsForSession); + + const featureFlagGroupOptions: UpGradeClientInterfaces.IFeatureFlagGroupOptions | null = + options?.featureFlagGroupOptions ?? + (options?.featureFlagUserGroupsForSession + ? { + useSingleGroupSet: { + groups: options.featureFlagUserGroupsForSession.groupsForSession, + includeStoredUserGroups: options.featureFlagUserGroupsForSession.includeStoredUserGroups, + }, + } + : null); + + this.setFeatureFlagGroupOptions(featureFlagGroupOptions); } - private validateFeatureFlagGroupOptions( - options: UpGradeClientInterfaces.IFeatureFlagOptions | null | undefined - ): void { - if (options && (!options.groupsForSession || options.includeStoredUserGroups === undefined)) { - throw new Error( - `${JSON.stringify( - options - )} featureFlagUserGroupsForSession must contain both groupsForSession and includeStoredUserGroups properties.` - ); + /** Registers a single groupset's definition and returns its (auto-generated or supplied) id. */ + private registerSingle(entry: UpGradeClientInterfaces.ISingleGroupSetOptions, label: string): string { + if (!entry?.groups) { + throw new Error(`${label}.groups is required.`); } + const id = UpgradeClient.generateGroupsetId(entry.groups, entry.includeStoredUserGroups); + this.dataService.registerGroupsetDefinition(id, { + groups: entry.groups, + includeStoredUserGroups: entry.includeStoredUserGroups, + }); + return id; + } + + /** Registers one subGroupsets entry (groupsetId is required — never auto-generated) and returns it. */ + private registerSub(entry: UpGradeClientInterfaces.ISubGroupSetOptions): string { + if (!entry?.groupsetId) { + throw new Error('Each subGroupsets entry requires a groupsetId.'); + } + if (!entry.groups) { + throw new Error(`subGroupsets entry "${entry.groupsetId}" requires groups.`); + } + this.dataService.registerGroupsetDefinition(entry.groupsetId, { + groups: entry.groups, + includeStoredUserGroups: entry.includeStoredUserGroups, + }); + return entry.groupsetId; } /** - * Sets the feature flag session user group options. - * - * Note: This is a convenience method, this can also be set directly in the constructor of UpgradeClient. - * See example usage in the constructor documentation. + * Serializes a groups object into a deterministic, human-readable string: keys sorted + * alphabetically, each key's values sorted, formatted as `key:[v1,v2],key2:[v3]`. + */ + private static serializeGroups(groups: Record): string { + return Object.keys(groups) + .sort() + .map((key) => `${key}:[${[...groups[key]].sort().join(',')}]`) + .join(','); + } + + /** + * Auto-generates a composite groupset id for `useSingleGroupSet`/`mainGroupset` from its groups. + * Stored-user mode (no groups) gets `*`; merged mode gets `*,` + the serialized groups (e.g. + * `*,schoolId:[1,2]`) since stored groups are unpredictable ahead of time; ephemeral mode gets + * just the serialized groups, since no stored groups are involved at all. + */ + private static generateGroupsetId( + groups: Record | undefined, + includeStoredUserGroups: boolean | undefined + ): string { + if (!groups) { + return DEFAULT_GROUPSET_ID; + } + const serialized = UpgradeClient.serializeGroups(groups); + return includeStoredUserGroups ? `${DEFAULT_GROUPSET_ID},${serialized}` : serialized; + } + + /** + * Sets the active feature flag groupset configuration used when `getAllFeatureFlags()`/ + * `hasFeatureFlag()` are called with no arguments. See the constructor's `featureFlagGroupOptions` + * documentation for the full `useSingleGroupSet`/`useMultipleGroupSets` breakdown. * - * Note: Calling this clears any cached feature flags, since those were resolved against the previous - * groups. The next `getAllFeatureFlags()`/`hasFeatureFlag()` call will refetch against the new groups. - * If you need to retain flags for the previous groups, capture the array returned by - * `getAllFeatureFlags()` before switching. + * Note: groupset ids are content-addressed (derived from the groups themselves, unless you + * supply your own `groupsetId` for a subGroupsets entry), so reconfiguring with different groups + * naturally lands on a fresh, never-yet-fetched id — the next `getAllFeatureFlags()`/ + * `hasFeatureFlag()` call will refetch it. No explicit cache-clear is needed for that case; other + * previously-fetched groupsets are left untouched (this is additive/upsert, not a wholesale reset). * * @example * ```typescript + * // Ephemeral, single groupset: + * upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['testClass'] } } }); * - * **Scenario 1: Session-only groups (Ephemeral user request)** - * const options: UpGradeClientInterfaces.IFeatureFlagOptions = { - * groupsForSession: { classId: ['testClass'] }, - * includeStoredUserGroups: false - * }; - * ``` + * // Merged, single groupset: + * upgradeClient.setFeatureFlagGroupOptions({ + * useSingleGroupSet: { groups: { classId: ['testClass'] }, includeStoredUserGroups: true }, + * }); * - * **Scenario 2: Merged groups (Merged stored/ephemeral groups request mode)** - * ```typescript - * const options: UpGradeClientInterfaces.IFeatureFlagOptions = { - * groupsForSession: { classId: ['testClass'] }, - * includeStoredUserGroups: true - * }; - * ``` + * // Multiple groupsets, with a mainGroupset: + * upgradeClient.setFeatureFlagGroupOptions({ + * useMultipleGroupSets: { + * mainGroupset: { groups: { schoolId: ['school-a', 'school-b'] } }, + * subGroupsets: [ + * { groupsetId: 'school-a', groups: { schoolId: ['school-a'] } }, + * { groupsetId: 'school-b', groups: { schoolId: ['school-b'] } }, + * ], + * }, + * }); * - * **Scenario 3: Default behavior (Standard mode)** - * Note this is the default behavior and does not need to be set, unless clearing previously set groupsForSession options - - * ```typescript - * const options: UpGradeClientInterfaces.IFeatureFlagOptions = null; + * // Default behavior (standard stored-user lookup) — also clears any previous configuration: + * upgradeClient.setFeatureFlagGroupOptions(null); * ``` */ + public setFeatureFlagGroupOptions( + options: UpGradeClientInterfaces.IFeatureFlagGroupOptions | null | undefined + ): void { + if (options == null) { + this.dataService.registerGroupsetDefinition(DEFAULT_GROUPSET_ID, {}); + this.activeConfig = { kind: 'single', groupsetId: DEFAULT_GROUPSET_ID }; + return; + } + + if (options.useSingleGroupSet && options.useMultipleGroupSets) { + throw new Error( + 'featureFlagGroupOptions must provide either useSingleGroupSet or useMultipleGroupSets, not both.' + ); + } + + if (options.useMultipleGroupSets) { + const { mainGroupset, subGroupsets } = options.useMultipleGroupSets; + if (!subGroupsets || subGroupsets.length === 0) { + throw new Error('useMultipleGroupSets.subGroupsets must contain at least one entry.'); + } + const mainGroupsetId = mainGroupset ? this.registerSingle(mainGroupset, 'mainGroupset') : null; + const subGroupsetIds = subGroupsets.map((entry) => this.registerSub(entry)); + this.activeConfig = { kind: 'multiple', mainGroupsetId, subGroupsetIds }; + return; + } + if (options.useSingleGroupSet) { + const groupsetId = this.registerSingle(options.useSingleGroupSet, 'useSingleGroupSet'); + this.activeConfig = { kind: 'single', groupsetId }; + return; + } + + // A truthy but empty options object — treat the same as null (reset to default). + this.dataService.registerGroupsetDefinition(DEFAULT_GROUPSET_ID, {}); + this.activeConfig = { kind: 'single', groupsetId: DEFAULT_GROUPSET_ID }; + } + + /** + * @deprecated Use `setFeatureFlagGroupOptions` with `useSingleGroupSet` instead. Kept as a thin + * forwarding wrapper — existing callers of this method continue to work unchanged. + */ public setFeatureFlagUserGroupsForSession( featureFlagOptions: UpGradeClientInterfaces.IFeatureFlagOptions | null | undefined ): void { - this.validateFeatureFlagGroupOptions(featureFlagOptions); - - this.apiService.setFeatureFlagUserGroupsForSession( - featureFlagOptions?.groupsForSession, - featureFlagOptions?.includeStoredUserGroups - ); - - this.dataService.clearFeatureFlags(); + if (featureFlagOptions == null) { + this.setFeatureFlagGroupOptions(null); + return; + } + if (!featureFlagOptions.groupsForSession || featureFlagOptions.includeStoredUserGroups === undefined) { + throw new Error( + `${JSON.stringify( + featureFlagOptions + )} featureFlagUserGroupsForSession must contain both groupsForSession and includeStoredUserGroups properties.` + ); + } + this.setFeatureFlagGroupOptions({ + useSingleGroupSet: { + groups: featureFlagOptions.groupsForSession, + includeStoredUserGroups: featureFlagOptions.includeStoredUserGroups, + }, + }); } /** @@ -508,46 +627,189 @@ export default class UpgradeClient { markExperimentPoint = this.markDecisionPoint; /** - * Fetches flags for the user given a context and stores them in the data service. - * @param options.ignoreCache If true, it will ignore the cached feature flags and fetch fresh data from the API. + * Fetches feature flags per the active configuration (see `setFeatureFlagGroupOptions`), or an + * ad-hoc override for this call only. + * + * With no arguments (or `ignoreCache`/nothing else set): uses the active configuration. + * `useSingleGroupSet` config → flat `string[]`. `useMultipleGroupSets` config → + * `{ mainGroupset?: string[]; subGroupsets: Record }`. Cache hits are served + * from cache; cache misses are aggregated into a single batched request. + * + * Passing `useSingleGroupSet`/`useMultipleGroupSets` directly evaluates that ad-hoc configuration + * for this call only, without touching the active configuration — the same shape-in/shape-out + * rule applies. `ignoreCache: true` forces a refetch of whatever's relevant to this call, upserted + * into the cache; everything else already fetched is left untouched. * * @example * ```typescript * const featureFlags = await upgradeClient.getAllFeatureFlags(); * console.log(featureFlags); // ['feature1', 'feature2', 'feature3'] - * ``` * - * NOTE: See `#featureFlagUserGroupsForSession` option explanation in the constructor of UpgradeClient - * to see configurations that may affect the responses to this method + * const bySchool = await upgradeClient.getAllFeatureFlags({ + * useSingleGroupSet: { groups: { schoolId: ['school-a'] } }, + * }); + * console.log(bySchool); // ['feature1'] — still a flat array, since the input was singular + * ``` */ + async getAllFeatureFlags( + options: UpGradeClientInterfaces.IGetAllFeatureFlagsOptions = {} + ): Promise { + const { ignoreCache = false, useSingleGroupSet, useMultipleGroupSets } = options; - async getAllFeatureFlags(options = { ignoreCache: false }): Promise { - let response = options.ignoreCache ? null : this.dataService.getFeatureFlags(); - if (response == null) { - response = await this.apiService.getAllFeatureFlags(); - if (Array.isArray(response)) { - this.dataService.setFeatureFlags(response); + if (useSingleGroupSet && useMultipleGroupSets) { + throw new Error('getAllFeatureFlags must be given either useSingleGroupSet or useMultipleGroupSets, not both.'); + } + + if (useMultipleGroupSets) { + const { mainGroupset, subGroupsets } = useMultipleGroupSets; + if (!subGroupsets || subGroupsets.length === 0) { + throw new Error('useMultipleGroupSets.subGroupsets must contain at least one entry.'); } + const mainGroupsetId = mainGroupset ? this.registerSingle(mainGroupset, 'mainGroupset') : null; + const subGroupsetIds = subGroupsets.map((entry) => this.registerSub(entry)); + return this.fetchMultiple(mainGroupsetId, subGroupsetIds, ignoreCache); } - return response; + + if (useSingleGroupSet) { + const groupsetId = this.registerSingle(useSingleGroupSet, 'useSingleGroupSet'); + return this.fetchSingle(groupsetId, ignoreCache); + } + + if (this.activeConfig.kind === 'single') { + return this.fetchSingle(this.activeConfig.groupsetId, ignoreCache); + } + return this.fetchMultiple(this.activeConfig.mainGroupsetId, this.activeConfig.subGroupsetIds, ignoreCache); + } + + private async fetchSingle(groupsetId: string, ignoreCache: boolean): Promise { + if (!ignoreCache) { + const cached = this.dataService.getFeatureFlagsForGroupset(groupsetId); + if (cached != null) { + return cached; + } + } + + const definition = this.dataService.getGroupsetDefinition(groupsetId) ?? {}; + const response = await this.apiService.getAllFeatureFlags({ + useSingleGroupSet: { groups: definition.groups, includeStoredUserGroups: definition.includeStoredUserGroups }, + }); + const flags = Array.isArray(response) ? response : []; + this.dataService.setFeatureFlagsForGroupset(groupsetId, flags); + return flags; + } + + private async fetchMultiple( + mainGroupsetId: string | null, + subGroupsetIds: string[], + ignoreCache: boolean + ): Promise { + const allIds = [...(mainGroupsetId ? [mainGroupsetId] : []), ...subGroupsetIds]; + const idsToFetch = ignoreCache + ? allIds + : allIds.filter((id) => this.dataService.getFeatureFlagsForGroupset(id) == null); + + if (idsToFetch.length) { + const mainNeedsFetch = mainGroupsetId != null && idsToFetch.includes(mainGroupsetId); + const subIdsToFetch = idsToFetch.filter((id) => id !== mainGroupsetId); + + if (mainNeedsFetch && subIdsToFetch.length === 0) { + // Only the mainGroupset needs (re)fetching — the plain single-groupset shape covers it, + // since the backend requires useMultipleGroupSets.subGroupsets to be non-empty. + const mainDef = this.dataService.getGroupsetDefinition(mainGroupsetId) ?? {}; + const response = await this.apiService.getAllFeatureFlags({ + useSingleGroupSet: { groups: mainDef.groups, includeStoredUserGroups: mainDef.includeStoredUserGroups }, + }); + this.dataService.setFeatureFlagsForGroupset(mainGroupsetId, Array.isArray(response) ? response : []); + } else { + const response = await this.apiService.getAllFeatureFlags({ + useMultipleGroupSets: { + ...(mainNeedsFetch + ? { + mainGroupset: (this.dataService.getGroupsetDefinition(mainGroupsetId) ?? + {}) as UpGradeClientInterfaces.ISingleGroupSetOptions, + } + : {}), + subGroupsets: subIdsToFetch.map( + (id) => + ({ + groupsetId: id, + ...(this.dataService.getGroupsetDefinition(id) ?? {}), + } as UpGradeClientInterfaces.ISubGroupSetOptions) + ), + }, + }); + if (response && !Array.isArray(response)) { + if (response.mainGroupset && mainGroupsetId) { + this.dataService.setFeatureFlagsForGroupset(mainGroupsetId, response.mainGroupset); + } + this.dataService.setFeatureFlagsForGroupsets(response.subGroupsets ?? {}); + } + } + } + + const result: UpGradeClientInterfaces.IMultiGroupSetFeatureFlagsResult = { subGroupsets: {} }; + if (mainGroupsetId) { + result.mainGroupset = this.dataService.getFeatureFlagsForGroupset(mainGroupsetId) ?? []; + } + subGroupsetIds.forEach((id) => { + result.subGroupsets[id] = this.dataService.getFeatureFlagsForGroupset(id) ?? []; + }); + return result; } /** - * Checks if a specific feature flag is enabled for the user. - * Note: will await a promise if feature flags have not been fetched yet! + * Checks if a specific feature flag is enabled. + * + * With no `groupsetId`: uses the active configuration. `useSingleGroupSet` (or default) mode — + * no id needed. `useMultipleGroupSets` mode — resolves to `mainGroupset` if one is configured; + * throws otherwise, since there's nothing to default to. + * + * With a `groupsetId`: reads that groupset's cache; on a miss, rehydrates it by refetching just + * that one groupset, using whatever definition was registered for it (from construction, + * `setFeatureFlagGroupOptions`, or a prior `getAllFeatureFlags` call) — throws if the id was + * never registered at all. * * @example * ```typescript * const isFeatureEnabled = await upgradeClient.hasFeatureFlag('feature1'); - * console.log(isFeatureEnabled); // true or false + * const isEnabledForSchool = await upgradeClient.hasFeatureFlag('feature1', 'school-a'); * ``` - * - * NOTE: See `#featureFlagUserGroupsForSession` option explanation in the constructor of UpgradeClient - * to see configurations that may affect the responses to this method */ - public async hasFeatureFlag(key: string): Promise { - await this.getAllFeatureFlags(); - return this.dataService.hasFeatureFlag(key); + public async hasFeatureFlag(key: string, groupsetId?: string): Promise { + const resolvedGroupsetId = groupsetId ?? this.resolveDefaultGroupsetId(); + await this.ensureGroupsetFetched(resolvedGroupsetId); + return this.dataService.hasFeatureFlagForGroupset(key, resolvedGroupsetId); + } + + private resolveDefaultGroupsetId(): string { + if (this.activeConfig.kind === 'single') { + return this.activeConfig.groupsetId; + } + if (this.activeConfig.mainGroupsetId) { + return this.activeConfig.mainGroupsetId; + } + throw new Error( + 'hasFeatureFlag(key) requires a groupsetId when useMultipleGroupSets has no mainGroupset configured — use hasFeatureFlag(key, groupsetId).' + ); + } + + /** Fetches a single groupset by id if it isn't already cached, using its registered definition. */ + private async ensureGroupsetFetched(groupsetId: string): Promise { + if (this.dataService.getFeatureFlagsForGroupset(groupsetId) != null) { + return; + } + + const definition: IGroupsetDefinition = this.dataService.getGroupsetDefinition(groupsetId); + if (!definition) { + throw new Error(`No feature flags have been fetched or configured for groupset id "${groupsetId}".`); + } + + // Rehydrating a single id (whether it plays a "main" or "sub" role) is always just a plain + // single-groupset evaluation — the multi-groupset framing doesn't matter for one id alone. + const response = await this.apiService.getAllFeatureFlags({ + useSingleGroupSet: { groups: definition.groups, includeStoredUserGroups: definition.includeStoredUserGroups }, + }); + this.dataService.setFeatureFlagsForGroupset(groupsetId, Array.isArray(response) ? response : []); } /** diff --git a/clientlibs/js/src/types/Interfaces.ts b/clientlibs/js/src/types/Interfaces.ts index 28a2df222b..5019a182b8 100644 --- a/clientlibs/js/src/types/Interfaces.ts +++ b/clientlibs/js/src/types/Interfaces.ts @@ -12,21 +12,80 @@ export namespace UpGradeClientInterfaces { clientSessionId?: string; token?: string; httpClient?: UpGradeClientInterfaces.IHttpClientWrapper; - featureFlagUserGroupsForSession: IFeatureFlagOptions | null; } export interface IConfigOptions { token?: string; clientSessionId?: string; httpClient?: UpGradeClientInterfaces.IHttpClientWrapper; + featureFlagGroupOptions?: IFeatureFlagGroupOptions | null; + /** @deprecated Use `featureFlagGroupOptions` with `useSingleGroupSet` instead. */ featureFlagUserGroupsForSession?: IFeatureFlagOptions | null; } + /** @deprecated Use `useSingleGroupSet` (via `featureFlagGroupOptions`) instead. */ export interface IFeatureFlagOptions { groupsForSession: Record; includeStoredUserGroups: boolean; } + /** + * One groupset: which groups to evaluate feature flags under, and whether to merge them with + * the user's stored groups. + * + * - Omit `groups` entirely (i.e. omit the whole `useSingleGroupSet`/entry) → stored-user mode. + * - `groups` alone, or with `includeStoredUserGroups: false` → ephemeral mode (caller-provided + * groups only). `includeStoredUserGroups` is optional — omitting it is the same as `false`. + * - `groups` + `includeStoredUserGroups: true` → merged mode (stored + caller-provided groups). + * `true` must always be explicit; it is never inferred. + */ + export interface ISingleGroupSetOptions { + groups: Record; + includeStoredUserGroups?: boolean; + } + + /** One named entry in `useMultipleGroupSets.subGroupsets` — `groupsetId` is required and is the + * key both the response and `hasFeatureFlag(key, groupsetId)` use to address this entry. */ + export interface ISubGroupSetOptions extends ISingleGroupSetOptions { + groupsetId: string; + } + + export interface IMultipleGroupSetsOptions { + /** + * Optional. When provided, this groupset's flags are what `hasFeatureFlag(key)` (no id) + * resolves to, and `getAllFeatureFlags()` returns them under the `mainGroupset` key. When + * omitted, `hasFeatureFlag(key)` with no id throws — there is nothing to default to. + */ + mainGroupset?: ISingleGroupSetOptions; + /** At least one named groupset, fetched alongside `mainGroupset` in a single request. */ + subGroupsets: ISubGroupSetOptions[]; + } + + /** + * Configures how `/v6/featureflag` requests are evaluated — exactly one of `useSingleGroupSet` + * (the classic one-groupset approach: `getAllFeatureFlags()` returns a flat `string[]` and + * `hasFeatureFlag(key)` needs no id) or `useMultipleGroupSets` (`getAllFeatureFlags()` returns + * `{ mainGroupset?: string[]; subGroupsets: Record }`). + */ + export interface IFeatureFlagGroupOptions { + useSingleGroupSet?: ISingleGroupSetOptions; + useMultipleGroupSets?: IMultipleGroupSetsOptions; + } + + export interface IGetAllFeatureFlagsOptions { + ignoreCache?: boolean; + /** Ad-hoc single-groupset override for this call only — does not touch persistent config. */ + useSingleGroupSet?: ISingleGroupSetOptions; + /** Ad-hoc multiple-groupsets override for this call only — does not touch persistent config. */ + useMultipleGroupSets?: IMultipleGroupSetsOptions; + } + + /** `getAllFeatureFlags()`'s return shape when `useMultipleGroupSets` is active. */ + export interface IMultiGroupSetFeatureFlagsResult { + mainGroupset?: string[]; + subGroupsets: Record; + } + export interface IResponse { status: boolean; data?: any; diff --git a/clientlibs/js/src/types/requests.ts b/clientlibs/js/src/types/requests.ts index 87efddfcb5..bbd3bc6094 100644 --- a/clientlibs/js/src/types/requests.ts +++ b/clientlibs/js/src/types/requests.ts @@ -30,8 +30,11 @@ export namespace UpGradeClientRequests { } | { context: string; - groupsForSession: Record; - includeStoredUserGroups: boolean; + useSingleGroupSet: UpGradeClientInterfaces.ISingleGroupSetOptions; + } + | { + context: string; + useMultipleGroupSets: UpGradeClientInterfaces.IMultipleGroupSetsOptions; }; export interface IMarkDecisionPointRequestBody { diff --git a/packages/backend/src/api/controllers/ExperimentClientController.v6.ts b/packages/backend/src/api/controllers/ExperimentClientController.v6.ts index 814b5bcbe6..06da9a7483 100644 --- a/packages/backend/src/api/controllers/ExperimentClientController.v6.ts +++ b/packages/backend/src/api/controllers/ExperimentClientController.v6.ts @@ -654,25 +654,34 @@ export class ExperimentClientController { * description: | * Get feature flags that have been assigned to the user. * - * This endpoint supports three different modes of operation based on the optional parameters: + * **Single-groupset modes** (via `useSingleGroupSet`, or the deprecated top-level + * `groupsForSession`/`includeStoredUserGroups`) — response is a flat array of flag keys: * * **Stored-user Mode** (Standard stored user lookup): - * - Omit both `groupsForSession` and `includeStoredUserGroups` parameters + * - Omit `useSingleGroupSet` (and the deprecated top-level fields) entirely * - Uses only stored user groups from the database * - User must already have been initialized, will 404 if user does not exist * - * **Ephemeral Mode** (Session-only groups): - * - Set `includeStoredUserGroups` to `false` and provide `groupsForSession` - * - Uses only the groups provided in the session, ignoring any stored user groups. + * **Ephemeral Mode** (caller-provided groups only): + * - Provide `useSingleGroupSet.groups`; `includeStoredUserGroups` is optional and + * defaults to `false` when omitted + * - Uses only the provided groups, ignoring any stored user groups * - Does not require the user to be initialized (it will bypass stored user lookup) - * - Useful when complete group information is always provided at runtime. * - * **Merged Mode** (Stored + Session groups): - * - Set `includeStoredUserGroups` to `true` and provide `groupsForSession` - * - User must already have been initialized, will 404 if user does not exist. - * - Session groups are merged with stored groups if they don't already exist for stored user. - * - Session groups are never persisted. - * - Useful for adding context-specific ephemeral groups to an existing user. + * **Merged Mode** (stored + caller-provided groups): + * - Provide `useSingleGroupSet.groups` and `useSingleGroupSet.includeStoredUserGroups: true` + * - User must already have been initialized, will 404 if user does not exist + * - Provided groups are merged with stored groups; never persisted + * + * **Batch mode** (via `useMultipleGroupSets`) — response is + * `{ mainGroupset?: string[], subGroupsets: Record }`: + * + * - `mainGroupset` is optional and follows the same three modes as `useSingleGroupSet` + * above; when provided, its flags are returned under the `mainGroupset` key + * - `subGroupsets` is a required array of `{ groupsetId, groups, includeStoredUserGroups? }` + * entries, each independently following the same three modes, evaluated against the + * same authenticated user (`User-Id` header) — response keys them by `groupsetId` + * - Up to 50 `subGroupsets` entries per request * * consumes: * - application/json @@ -696,55 +705,142 @@ export class ExperimentClientController { * type: string * example: "test-context" * description: The context for feature flag evaluation + * useSingleGroupSet: + * type: object + * properties: + * groups: + * type: object + * additionalProperties: + * type: array + * items: + * type: string + * includeStoredUserGroups: + * type: boolean + * description: Single-groupset configuration (mutually exclusive with useMultipleGroupSets) + * useMultipleGroupSets: + * type: object + * required: + * - subGroupsets + * properties: + * mainGroupset: + * type: object + * properties: + * groups: + * type: object + * additionalProperties: + * type: array + * items: + * type: string + * includeStoredUserGroups: + * type: boolean + * subGroupsets: + * type: array + * maxItems: 50 + * items: + * type: object + * required: + * - groupsetId + * - groups + * properties: + * groupsetId: + * type: string + * groups: + * type: object + * additionalProperties: + * type: array + * items: + * type: string + * includeStoredUserGroups: + * type: boolean + * description: Batch configuration (mutually exclusive with useSingleGroupSet) * groupsForSession: * type: object * additionalProperties: * type: array * items: * type: string - * example: - * schoolId: ["temporary-school-id"] - * description: Optional groups to provide for the session (not persisted) + * description: "Deprecated — use useSingleGroupSet.groups instead" * includeStoredUserGroups: * type: boolean - * description: Whether to include stored user groups in evaluation - * example: false + * description: "Deprecated — use useSingleGroupSet.includeStoredUserGroups instead" * description: Feature flag request parameters * examples: * normal_mode: * summary: Normal Mode - Standard stored user lookup - * description: Uses only stored user groups from the database * value: * context: "test-context" - * ephemeral_mode: - * summary: Ephemeral Mode - Session-only groups - * description: Uses only session groups, ignoring stored user groups + * single_groupset_ephemeral: + * summary: Single groupset - ephemeral * value: * context: "test-context" - * groupsForSession: - * schoolId: ["demo-school"] - * classId: ["demo-class-advanced"] - * includeStoredUserGroups: false - * merged_mode: - * summary: Merged Mode - Stored + Session groups - * description: Combines stored user groups (if exists) with session groups + * useSingleGroupSet: + * groups: + * schoolId: ["demo-school"] + * classId: ["demo-class-advanced"] + * single_groupset_merged: + * summary: Single groupset - merged + * value: + * context: "test-context" + * useSingleGroupSet: + * groups: + * classId: ["temp-class-123", "special-session"] + * includeStoredUserGroups: true + * multiple_group_sets: + * summary: Batch - mainGroupset plus subGroupsets + * value: + * context: "test-context" + * useMultipleGroupSets: + * mainGroupset: + * groups: + * classId: ["demo-class-advanced"] + * schoolId: ["school-a", "school-b"] + * subGroupsets: + * - groupsetId: "school-a" + * groups: + * schoolId: ["school-a"] + * - groupsetId: "school-b" + * groups: + * schoolId: ["school-b"] + * deprecated_top_level: + * summary: Deprecated - top-level groupsForSession still works * value: * context: "test-context" * groupsForSession: - * classId: ["temp-class-123", "special-session"] - * includeStoredUserGroups: true + * schoolId: ["demo-school"] + * includeStoredUserGroups: false * produces: * - application/json * tags: * - Client Side SDK * responses: * '200': - * description: Feature flags list + * description: > + * A flat array of flag keys for single-groupset modes, or + * `{ mainGroupset?: string[], subGroupsets: Record }` when + * `useMultipleGroupSets` was used. * schema: - * type: array - * items: - * type: string - * example: ["NEW_FEATURE", "TEST_FLAG"] + * oneOf: + * - type: array + * items: + * type: string + * example: ["NEW_FEATURE", "TEST_FLAG"] + * - type: object + * properties: + * mainGroupset: + * type: array + * items: + * type: string + * subGroupsets: + * type: object + * additionalProperties: + * type: array + * items: + * type: string + * example: + * mainGroupset: ["NEW_FEATURE"] + * subGroupsets: + * school-a: ["NEW_FEATURE"] + * school-b: ["NEW_FEATURE", "TEST_FLAG"] * '400': * description: BadRequestError - InvalidParameterValue * '401': @@ -759,7 +855,16 @@ export class ExperimentClientController { @Req() request: AppRequest, @Body({ validate: true }) featureFlagRequest: FeatureFlagRequestValidator - ): Promise { + ): Promise }> { + if (featureFlagRequest.useMultipleGroupSets) { + return this.featureFlagService.getKeysForMultipleGroupSets( + request.userDoc, + request.userDocsBySubGroupset ?? {}, + featureFlagRequest.context, + request.logger + ); + } + const experimentUserDoc = request.userDoc; return this.featureFlagService.getKeys(experimentUserDoc, featureFlagRequest.context, request.logger); } diff --git a/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts b/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts index 1b848fe562..fa6589aaeb 100644 --- a/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts +++ b/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts @@ -4,22 +4,42 @@ import { IsString, IsBoolean, IsObject, + IsArray, + ArrayMinSize, + ValidateNested, registerDecorator, ValidationOptions, ValidationArguments, } from 'class-validator'; +import { Type } from 'class-transformer'; + +export const MAX_SUB_GROUPSETS = 50; export type IGetAllFeatureFlagsRequestBody = | { context: string; } | { + /** @deprecated Use `useSingleGroupSet` instead. */ context: string; groupsForSession: Record; includeStoredUserGroups: boolean; + } + | { + context: string; + useSingleGroupSet: { groups: Record; includeStoredUserGroups?: boolean }; + } + | { + context: string; + useMultipleGroupSets: { + mainGroupset?: { groups: Record; includeStoredUserGroups?: boolean }; + subGroupsets: { groupsetId: string; groups: Record; includeStoredUserGroups?: boolean }[]; + }; }; -// Custom validation decorator to ensure both session properties are provided together +// Custom validation decorator to ensure both dev-era top-level session properties are provided +// together — kept exactly as-is for the deprecated groupsForSession/includeStoredUserGroups path, +// since existing callers (e.g. the Java/Python client libraries) already always pass both. const BothSessionPropertiesRequired = (validationOptions?: ValidationOptions) => { const registerBothSessionPropertiesRequired = (object: object, propertyName: string) => { registerDecorator({ @@ -46,18 +66,149 @@ const BothSessionPropertiesRequired = (validationOptions?: ValidationOptions) => return registerBothSessionPropertiesRequired; }; +// Custom validation decorator to ensure exactly one (or none) of the top-level configuration +// shapes is provided: the deprecated groupsForSession/includeStoredUserGroups pair, +// useSingleGroupSet, or useMultipleGroupSets. Mixing shapes on the same request is ambiguous. +const OnlyOneGroupConfigShape = (validationOptions?: ValidationOptions) => { + const registerOnlyOneGroupConfigShape = (object: object, propertyName: string) => { + registerDecorator({ + name: 'onlyOneGroupConfigShape', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const obj = args.object as any; + const shapesProvided = [ + obj.groupsForSession !== undefined || obj.includeStoredUserGroups !== undefined, + obj.useSingleGroupSet !== undefined, + obj.useMultipleGroupSets !== undefined, + ].filter(Boolean).length; + return shapesProvided <= 1; + }, + defaultMessage() { + return 'Provide at most one of: the deprecated groupsForSession/includeStoredUserGroups, useSingleGroupSet, or useMultipleGroupSets'; + }, + }, + }); + }; + + return registerOnlyOneGroupConfigShape; +}; + +// Custom validation decorator to ensure every subGroupsets entry has a unique groupsetId, since +// callers use that id as the key to retrieve their result. +const UniqueSubGroupsetIds = (validationOptions?: ValidationOptions) => { + const registerUniqueSubGroupsetIds = (object: object, propertyName: string) => { + registerDecorator({ + name: 'uniqueSubGroupsetIds', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + if (!Array.isArray(value)) { + return true; + } + const ids = value.map((entry) => entry?.groupsetId).filter((id) => id !== undefined); + return ids.length === new Set(ids).size; + }, + defaultMessage() { + return 'subGroupsets groupsetId values must be unique'; + }, + }, + }); + }; + + return registerUniqueSubGroupsetIds; +}; + +// Custom validation decorator to cap the number of subGroupsets entries in one request. A custom +// decorator (rather than the built-in ArrayMaxSize) is used so the error message can name the +// actual count sent, not just the limit. +const MaxSubGroupsetsAllowed = (max: number, validationOptions?: ValidationOptions) => { + const registerMaxSubGroupsetsAllowed = (object: object, propertyName: string) => { + registerDecorator({ + name: 'maxSubGroupsetsAllowed', + target: object.constructor, + propertyName: propertyName, + constraints: [max], + options: validationOptions, + validator: { + validate(value: any) { + return !Array.isArray(value) || value.length <= max; + }, + defaultMessage(args: ValidationArguments) { + const count = Array.isArray(args.value) ? args.value.length : 0; + return `Received ${count} subGroupsets, which exceeds the maximum of ${max} allowed`; + }, + }, + }); + }; + + return registerMaxSubGroupsetsAllowed; +}; + +export class SingleGroupSetValidator { + @IsNotEmpty() + @IsObject() + public groups: Record; + + /** Optional. Defaults to `false` (ephemeral) when omitted. */ + @IsOptional() + @IsBoolean() + public includeStoredUserGroups?: boolean; +} + +export class SubGroupSetValidator extends SingleGroupSetValidator { + @IsNotEmpty() + @IsString() + public groupsetId: string; +} + +export class MultipleGroupSetsValidator { + @IsOptional() + @ValidateNested() + @Type(() => SingleGroupSetValidator) + public mainGroupset?: SingleGroupSetValidator; + + @IsArray() + @ArrayMinSize(1) + @MaxSubGroupsetsAllowed(MAX_SUB_GROUPSETS) + @ValidateNested({ each: true }) + @Type(() => SubGroupSetValidator) + @UniqueSubGroupsetIds() + public subGroupsets: SubGroupSetValidator[]; +} + export class FeatureFlagRequestValidator { @IsNotEmpty() @IsString() public context: string; + /** @deprecated Use `useSingleGroupSet` instead. */ @IsOptional() @IsObject() @BothSessionPropertiesRequired() + @OnlyOneGroupConfigShape() public groupsForSession?: Record; + /** @deprecated Use `useSingleGroupSet` instead. */ @IsOptional() @IsBoolean() @BothSessionPropertiesRequired() + @OnlyOneGroupConfigShape() public includeStoredUserGroups?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => SingleGroupSetValidator) + @OnlyOneGroupConfigShape() + public useSingleGroupSet?: SingleGroupSetValidator; + + @IsOptional() + @ValidateNested() + @Type(() => MultipleGroupSetsValidator) + @OnlyOneGroupConfigShape() + public useMultipleGroupSets?: MultipleGroupSetsValidator; } diff --git a/packages/backend/src/api/middlewares/UserCheckMiddleware.ts b/packages/backend/src/api/middlewares/UserCheckMiddleware.ts index b848f91c25..e0dafd677f 100644 --- a/packages/backend/src/api/middlewares/UserCheckMiddleware.ts +++ b/packages/backend/src/api/middlewares/UserCheckMiddleware.ts @@ -24,6 +24,20 @@ export class UserCheckMiddleware { req.logger.debug({ message: 'User Id is:', user_id }); } + if (req.url.endsWith('/v6/featureflag') && req.body?.useMultipleGroupSets) { + const resolved = await this.handleMultipleGroupSets(req, user_id); + if (resolved === null) { + const error = new Error(`User not found: ${user_id}`); + (error as any).type = SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED; + (error as any).httpCode = 404; + req.logger.error(error); + return next(error); + } + req.userDoc = resolved.main; + req.userDocsBySubGroupset = resolved.subGroupsets; + return next(); + } + let experimentUserDoc: RequestedExperimentUser; if (req.url.endsWith('/v6/featureflag')) { @@ -104,11 +118,22 @@ export class UserCheckMiddleware { */ private async handleProvidedGroupsForSession(req: AppRequest, user_id: string): Promise { // Note: validation of request will have occurred before this middleware + // Normalize the deprecated top-level groupsForSession/includeStoredUserGroups and the new + // useSingleGroupSet shape into the same two local values, so the logic (and log messages) + // below stay identical regardless of which shape the caller used. + const useSingleGroupSet = req.body.useSingleGroupSet as + | { groups: Record; includeStoredUserGroups?: boolean } + | undefined; + const groupsForSession: Record | undefined = + useSingleGroupSet?.groups ?? req.body.groupsForSession; + const includeStoredUserGroups: boolean | undefined = useSingleGroupSet + ? useSingleGroupSet.includeStoredUserGroups + : req.body.includeStoredUserGroups; - // Scenario 1: Session-only groups (ephemeral user mode) - // explicitly check if includeStoredUserGroups is exactly false and not just undefined - if (req.body.groupsForSession && req.body.includeStoredUserGroups === false) { - const experimentUserDoc = this.createSessionUser(user_id, req.body.groupsForSession); + // Scenario 1: Session-only groups (ephemeral user mode). includeStoredUserGroups is optional + // for useSingleGroupSet — omitted or explicitly false both mean "don't merge." + if (groupsForSession && includeStoredUserGroups !== true) { + const experimentUserDoc = this.createSessionUser(user_id, groupsForSession); req.logger.debug({ message: 'Created ephemeral user with session groups', @@ -125,9 +150,9 @@ export class UserCheckMiddleware { return null; // User not found, will be handled in the main middleware } - if (req.body.groupsForSession && req.body.includeStoredUserGroups) { + if (groupsForSession && includeStoredUserGroups === true) { // Scenario 2: Merged groups (Merged stored/ephemeral groups mode) - experimentUserDoc.group = this.mergeGroupsWithUniqueValues(experimentUserDoc.group, req.body.groupsForSession); + experimentUserDoc.group = this.mergeGroupsWithUniqueValues(experimentUserDoc.group, groupsForSession); req.logger.debug({ message: 'Merged session groups with stored user groups', @@ -144,6 +169,69 @@ export class UserCheckMiddleware { return experimentUserDoc; } + /** + * Resolves a mainGroupset (optional) plus one or more subGroupsets for a `useMultipleGroupSets` + * `/v6/featureflag` request. All entries share the same authenticated user (`User-Id` header) — + * only the group overrides differ — so the stored user doc is fetched at most once for the + * whole request, only if at least one entry actually needs it (i.e. isn't ephemeral). + * + * Returns `null` if a stored user doc was needed and none exists, mirroring the single-groupset + * 404 behavior for the whole request. + */ + private async handleMultipleGroupSets( + req: AppRequest, + user_id: string + ): Promise<{ main?: RequestedExperimentUser; subGroupsets: Record } | null> { + const { mainGroupset, subGroupsets } = req.body.useMultipleGroupSets as { + mainGroupset?: { groups: Record; includeStoredUserGroups?: boolean }; + subGroupsets: { groupsetId: string; groups: Record; includeStoredUserGroups?: boolean }[]; + }; + + const isEphemeral = (entry: { groups?: Record; includeStoredUserGroups?: boolean }) => + Boolean(entry.groups) && entry.includeStoredUserGroups !== true; + + const needsStoredUser = + (mainGroupset ? !isEphemeral(mainGroupset) : false) || subGroupsets.some((entry) => !isEphemeral(entry)); + + let storedUserDoc: RequestedExperimentUser | null = null; + if (needsStoredUser) { + storedUserDoc = await this.experimentUserService.getUserDoc(user_id, req.logger); + if (!storedUserDoc) { + return null; + } + } + + const resolveOne = (entry: { + groups?: Record; + includeStoredUserGroups?: boolean; + }): RequestedExperimentUser => { + if (isEphemeral(entry)) { + return this.createSessionUser(user_id, entry.groups); + } + if (entry.groups && entry.includeStoredUserGroups === true) { + return { + ...storedUserDoc, + group: this.mergeGroupsWithUniqueValues(storedUserDoc.group, entry.groups), + }; + } + return storedUserDoc; + }; + + const main = mainGroupset ? resolveOne(mainGroupset) : undefined; + const subGroupsetsResolved: Record = {}; + subGroupsets.forEach((entry) => { + subGroupsetsResolved[entry.groupsetId] = resolveOne(entry); + }); + + req.logger.debug({ + message: 'Resolved experiment users for feature flag useMultipleGroupSets', + hasMain: Boolean(main), + subGroupsetIds: Object.keys(subGroupsetsResolved), + }); + + return { main, subGroupsets: subGroupsetsResolved }; + } + /** * Creates a RequestedExperimentUser object for ephemeral user scenarios. * diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index 55bc496df2..b139453098 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -135,6 +135,78 @@ export class FeatureFlagService { return includedFeatureFlags.map((flags) => flags.key); } + /** + * `useMultipleGroupSets` counterpart to {@link getKeys}: evaluates the same set of flags against + * an optional mainGroupset plus one or more named subGroupsets (all belonging to the same + * authenticated user — see `UserCheckMiddleware.handleMultipleGroupSets`), in one pass. + * + * The cached flag list ({@link getCachedFlagsForKeys}) and precomputed-set fetch are naturally + * shared across every entry since `featureFlagLevelInclusionExclusion` itself caches per flag id, + * not per user — so this does not multiply DB/cache work by the number of groupsets. Exposure + * recording is deduped once per unique included flag across the whole batch (mainGroupset and + * subGroupsets alike), same as {@link getKeys} does for one user. + */ + public async getKeysForMultipleGroupSets( + mainDoc: RequestedExperimentUser | undefined, + subDocs: Record, + context: string, + logger: UpgradeLogger + ): Promise<{ mainGroupset?: string[]; subGroupsets: Record }> { + const entries: { groupsetId?: string; doc: RequestedExperimentUser }[] = [ + ...(mainDoc ? [{ doc: mainDoc }] : []), + ...Object.entries(subDocs).map(([groupsetId, doc]) => ({ groupsetId, doc })), + ]; + + logger.info({ + message: `getKeysForMultipleGroupSets: mainGroupset=${Boolean(mainDoc)}, ${ + Object.keys(subDocs).length + } subGroupset(s)`, + }); + + const filteredFeatureFlags = await this.getCachedFlagsForKeys(context); + + const resolvedEntries = await Promise.all( + entries.map(async ({ groupsetId, doc }) => { + if (!doc || !doc.id) { + logger.error({ + message: `User not defined in getKeysForMultipleGroupSets for "${groupsetId ?? 'mainGroupset'}"`, + }); + const error = new Error( + JSON.stringify({ + type: SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED, + message: 'User not defined in getKeysForMultipleGroupSets', + }) + ); + (error as any).type = SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED; + (error as any).httpCode = 404; + throw error; + } + + const included = await this.featureFlagLevelInclusionExclusion(filteredFeatureFlags, doc, context, logger); + return { groupsetId, userId: doc.id, ids: included.map((f) => f.id), keys: included.map((f) => f.key) }; + }) + ); + + const result: { mainGroupset?: string[]; subGroupsets: Record } = { subGroupsets: {} }; + resolvedEntries.forEach(({ groupsetId, keys }) => { + if (groupsetId === undefined) { + result.mainGroupset = keys; + } else { + result.subGroupsets[groupsetId] = keys; + } + }); + + const allIncludedFlagIds = new Set(resolvedEntries.flatMap((entry) => entry.ids)); + const anyUserId = resolvedEntries[0]?.userId; + if (allIncludedFlagIds.size > 0 && anyUserId) { + this.featureFlagExposureRepository + .recordExposureIfNotExists(Array.from(allIncludedFlagIds), anyUserId) + .catch((err) => logger.error({ message: 'Error saving FF exposures', err })); + } + + return result; + } + public async getCachedFlagsFromContext(context: string): Promise { const cacheKey = CACHE_PREFIX.FEATURE_FLAG_KEY_PREFIX + context; diff --git a/packages/backend/src/types/index.ts b/packages/backend/src/types/index.ts index 8852003848..9604f82ef7 100644 --- a/packages/backend/src/types/index.ts +++ b/packages/backend/src/types/index.ts @@ -54,5 +54,9 @@ export type EntitySegmentResolutionInput = Record; logger: UpgradeLogger; } diff --git a/packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts b/packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts new file mode 100644 index 0000000000..ee13d4ac3c --- /dev/null +++ b/packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts @@ -0,0 +1,251 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + FeatureFlagRequestValidator, + MAX_SUB_GROUPSETS, +} from '../../../../src/api/controllers/validators/FeatureFlagRequestValidator'; + +// These options mirror the global validation config applied in +// packages/backend/src/loaders/app/index.ts, which is what routing-controllers +// merges in for every `@Body({ validate: true })` parameter. Note: forbidNonWhitelisted +// is NOT set, so unknown properties are silently stripped rather than raising errors. +const WHITELIST_OPTIONS = { whitelist: true }; + +describe('FeatureFlagRequestValidator', () => { + describe('normal mode (context only)', () => { + it('passes with only context provided', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { context: 'test-context' }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors).toEqual([]); + }); + }); + + describe('deprecated top-level groupsForSession + includeStoredUserGroups', () => { + it('passes when both are provided together', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + groupsForSession: { schoolId: ['demo-school'] }, + includeStoredUserGroups: false, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors).toEqual([]); + }); + + it('fails when only groupsForSession is provided', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + groupsForSession: { schoolId: ['demo-school'] }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'groupsForSession')).toBe(true); + }); + + it('fails when only includeStoredUserGroups is provided', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + includeStoredUserGroups: true, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'includeStoredUserGroups')).toBe(true); + }); + }); + + describe('useSingleGroupSet', () => { + it('passes with groups only — includeStoredUserGroups is optional', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useSingleGroupSet: { groups: { schoolId: ['demo-school'] } }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors).toEqual([]); + }); + + it('passes with groups and includeStoredUserGroups', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useSingleGroupSet: { groups: { schoolId: ['demo-school'] }, includeStoredUserGroups: true }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors).toEqual([]); + }); + + it('fails when groups is missing', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useSingleGroupSet: { includeStoredUserGroups: true }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useSingleGroupSet')).toBe(true); + }); + + it('fails when combined with the deprecated top-level fields', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useSingleGroupSet: { groups: { schoolId: ['demo-school'] } }, + groupsForSession: { schoolId: ['demo-school'] }, + includeStoredUserGroups: false, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + const properties = errors.map((e) => e.property); + expect(properties).toEqual(expect.arrayContaining(['useSingleGroupSet'])); + }); + + it('fails when combined with useMultipleGroupSets', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useSingleGroupSet: { groups: { schoolId: ['demo-school'] } }, + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'a', groups: { schoolId: ['a'] } }] }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + const properties = errors.map((e) => e.property); + expect(properties).toEqual(expect.arrayContaining(['useSingleGroupSet', 'useMultipleGroupSets'])); + }); + }); + + describe('useMultipleGroupSets', () => { + it('passes with subGroupsets only (no mainGroupset)', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { + subGroupsets: [ + { groupsetId: 'sectionA', groups: { schoolId: ['a'] } }, + { groupsetId: 'sectionB', groups: { schoolId: ['b'] }, includeStoredUserGroups: true }, + ], + }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors).toEqual([]); + }); + + it('passes with a mainGroupset and subGroupsets', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a', 'b'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors).toEqual([]); + }); + + it('fails when subGroupsets is missing', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { mainGroupset: { groups: { schoolId: ['a'] } } }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useMultipleGroupSets')).toBe(true); + }); + + it('fails when subGroupsets is empty', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { subGroupsets: [] }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useMultipleGroupSets')).toBe(true); + }); + + it('fails when a subGroupsets entry is missing groupsetId', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { subGroupsets: [{ groups: { schoolId: ['a'] } }] }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useMultipleGroupSets')).toBe(true); + }); + + it('fails when a subGroupsets entry is missing groups', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'sectionA' }] }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useMultipleGroupSets')).toBe(true); + }); + + it('fails when subGroupsets groupsetIds are not unique', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { + subGroupsets: [ + { groupsetId: 'dup', groups: { schoolId: ['a'] } }, + { groupsetId: 'dup', groups: { schoolId: ['b'] } }, + ], + }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useMultipleGroupSets')).toBe(true); + }); + + it(`passes with exactly ${MAX_SUB_GROUPSETS} subGroupsets entries`, async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { + subGroupsets: Array.from({ length: MAX_SUB_GROUPSETS }, (_, i) => ({ + groupsetId: `section${i}`, + groups: { schoolId: [`s${i}`] }, + })), + }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors).toEqual([]); + }); + + it(`fails with more than ${MAX_SUB_GROUPSETS} subGroupsets entries, naming the actual count sent`, async () => { + const count = MAX_SUB_GROUPSETS + 1; + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useMultipleGroupSets: { + subGroupsets: Array.from({ length: count }, (_, i) => ({ + groupsetId: `section${i}`, + groups: { schoolId: [`s${i}`] }, + })), + }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + const useMultipleGroupSetsError = errors.find((e) => e.property === 'useMultipleGroupSets'); + expect(useMultipleGroupSetsError).toBeDefined(); + const nestedConstraints = Object.values(useMultipleGroupSetsError.children?.[0]?.constraints ?? {}); + expect(nestedConstraints).toEqual( + expect.arrayContaining([ + `Received ${count} subGroupsets, which exceeds the maximum of ${MAX_SUB_GROUPSETS} allowed`, + ]) + ); + }); + + it('fails when combined with the deprecated top-level fields', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + groupsForSession: { schoolId: ['a'] }, + includeStoredUserGroups: false, + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }] }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + const properties = errors.map((e) => e.property); + expect(properties).toEqual( + expect.arrayContaining(['groupsForSession', 'includeStoredUserGroups', 'useMultipleGroupSets']) + ); + }); + }); +}); diff --git a/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts b/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts index bda6cb155b..4d5c46776c 100644 --- a/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts +++ b/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts @@ -277,6 +277,142 @@ describe('UserCheckMiddleware Tests', () => { }); }); + describe('useSingleGroupSet', () => { + beforeEach(() => { + mockRequest.url = '/api/v6/featureflag'; + }); + + test('ephemeral: includeStoredUserGroups omitted defaults to false', async () => { + const userId = 'ephemeral-user'; + const groups = { classId: ['session-class'] }; + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { useSingleGroupSet: { groups } }; + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc.id).toBe(userId); + expect(mockRequest.userDoc.group).toEqual(groups); + expect(mockLogger.debug).toHaveBeenCalledWith({ + message: 'Created ephemeral user with session groups', + experimentUserDoc: expect.any(Object), + }); + }); + + test('merged: includeStoredUserGroups true merges with stored groups', async () => { + const userId = 'existing-user'; + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + storedUser.group = { classId: ['stored-class'] }; + mockExperimentUserService.setMockUser(userId, storedUser); + + (mockRequest.get as jest.Mock).mockReturnValue(userId); + mockRequest.body = { + useSingleGroupSet: { groups: { classId: ['session-class'] }, includeStoredUserGroups: true }, + }; + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc.group).toEqual({ classId: ['stored-class', 'session-class'] }); + }); + }); + + describe('useMultipleGroupSets', () => { + beforeEach(() => { + mockRequest.url = '/api/v6/featureflag'; + }); + + test('resolves an ephemeral mainGroupset plus ephemeral subGroupsets without a stored lookup', async () => { + const userId = 'ephemeral-user'; + const getUserDocSpy = jest.spyOn(mockExperimentUserService, 'getUserDoc'); + + mockRequest.body = { + useMultipleGroupSets: { + mainGroupset: { groups: { classId: ['classA', 'classB'] } }, + subGroupsets: [ + { groupsetId: 'sectionA', groups: { classId: ['classA'] } }, + { groupsetId: 'sectionB', groups: { classId: ['classB'] } }, + ], + }, + }; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(getUserDocSpy).not.toHaveBeenCalled(); + expect(mockRequest.userDoc.group).toEqual({ classId: ['classA', 'classB'] }); + expect(mockRequest.userDocsBySubGroupset.sectionA.group).toEqual({ classId: ['classA'] }); + expect(mockRequest.userDocsBySubGroupset.sectionB.group).toEqual({ classId: ['classB'] }); + }); + + test('leaves userDoc undefined when no mainGroupset is configured', async () => { + const userId = 'ephemeral-user-no-main'; + + mockRequest.body = { + useMultipleGroupSets: { + subGroupsets: [{ groupsetId: 'sectionA', groups: { classId: ['classA'] } }], + }, + }; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(mockRequest.userDoc).toBeUndefined(); + expect(mockRequest.userDocsBySubGroupset.sectionA.group).toEqual({ classId: ['classA'] }); + }); + + test('fetches the stored user once and shares it across a merged mainGroupset and standard subGroupset', async () => { + const userId = 'stored-user'; + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + storedUser.group = { classId: ['stored-class'] }; + mockExperimentUserService.setMockUser(userId, storedUser); + const getUserDocSpy = jest.spyOn(mockExperimentUserService, 'getUserDoc'); + + mockRequest.body = { + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['provided-school'] }, includeStoredUserGroups: true }, + subGroupsets: [{ groupsetId: 'standard' }], + }, + }; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(getUserDocSpy).toHaveBeenCalledTimes(1); + expect(mockRequest.userDoc.group).toEqual({ classId: ['stored-class'], schoolId: ['provided-school'] }); + expect(mockRequest.userDocsBySubGroupset.standard).toEqual(storedUser); + }); + + test('returns 404 when a non-ephemeral entry requires a stored user that does not exist', async () => { + const userId = 'missing-user'; + + mockRequest.body = { + useMultipleGroupSets: { + subGroupsets: [{ groupsetId: 'standard' }], + }, + }; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: `User not found: ${userId}`, + type: SERVER_ERROR.EXPERIMENT_USER_NOT_DEFINED, + httpCode: 404, + }) + ); + }); + }); + describe('Group merging functionality', () => { test('should merge groups with unique values', () => { const existing = { diff --git a/packages/backend/test/unit/services/FeatureFlagService.test.ts b/packages/backend/test/unit/services/FeatureFlagService.test.ts index 7298dc3f72..f0bc3a6f8b 100644 --- a/packages/backend/test/unit/services/FeatureFlagService.test.ts +++ b/packages/backend/test/unit/services/FeatureFlagService.test.ts @@ -1051,6 +1051,65 @@ describe('Feature Flag Service Testing', () => { }); }); + describe('getKeysForMultipleGroupSets', () => { + const includeAllFlag = { id: 'multi-flag-id', key: 'multi-key', filterMode: FILTER_MODE.INCLUDE_ALL }; + + it('evaluates a mainGroupset and subGroupsets independently, keyed by groupsetId', async () => { + const mainDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + const includedSubDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + const excludedSubDoc = { id: 'user123', group: { schoolId: ['excluded-school'] }, workingGroup: {} } as any; + const precomputed = module.get(FeatureFlagPrecomputedSegmentService); + + service.cacheService.wrap = jest.fn().mockResolvedValue([includeAllFlag]); + (precomputed.getPrecomputedSets as jest.Mock).mockResolvedValue( + new Map([[includeAllFlag.id, { inclusionIds: [], exclusionIds: ['schoolId:excluded-school'] }]]) + ); + + const result = await service.getKeysForMultipleGroupSets( + mainDoc, + { included: includedSubDoc, excluded: excludedSubDoc }, + 'context1', + logger + ); + + expect(result.mainGroupset).toEqual([includeAllFlag.key]); + expect(result.subGroupsets.included).toEqual([includeAllFlag.key]); + expect(result.subGroupsets.excluded).toEqual([]); + }); + + it('omits mainGroupset from the result when no mainDoc is provided', async () => { + const subDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + + service.cacheService.wrap = jest.fn().mockResolvedValue([includeAllFlag]); + const precomputed = module.get(FeatureFlagPrecomputedSegmentService); + (precomputed.getPrecomputedSets as jest.Mock).mockResolvedValue( + new Map([[includeAllFlag.id, { inclusionIds: [], exclusionIds: [] }]]) + ); + + const result = await service.getKeysForMultipleGroupSets(undefined, { sectionA: subDoc }, 'context1', logger); + + expect(result.mainGroupset).toBeUndefined(); + expect(result.subGroupsets.sectionA).toEqual([includeAllFlag.key]); + }); + + it('records exposure once per unique included flag across the whole batch', async () => { + const mainDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + const subDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + const exposureRepo = module.get(getRepositoryToken(FeatureFlagExposureRepository)) as any; + const precomputed = module.get(FeatureFlagPrecomputedSegmentService); + + service.cacheService.wrap = jest.fn().mockResolvedValue([includeAllFlag]); + (precomputed.getPrecomputedSets as jest.Mock).mockResolvedValue( + new Map([[includeAllFlag.id, { inclusionIds: [], exclusionIds: [] }]]) + ); + + await service.getKeysForMultipleGroupSets(mainDoc, { sectionA: subDoc, sectionB: subDoc }, 'context1', logger); + + expect(exposureRepo.recordExposureIfNotExists).toHaveBeenCalledTimes(1); + expect(exposureRepo.recordExposureIfNotExists).toHaveBeenCalledWith([includeAllFlag.id], 'user123'); + }); + }); + describe('precomputed recompute + seed triggers', () => { it('seeds an empty precomputed row in-transaction when a flag is created', async () => { const precomputed = module.get(FeatureFlagPrecomputedSegmentService); diff --git a/postman/ClientAPI.postman_collection.json b/postman/ClientAPI.postman_collection.json index a4e59e1621..979ad7bf0c 100644 --- a/postman/ClientAPI.postman_collection.json +++ b/postman/ClientAPI.postman_collection.json @@ -205,7 +205,7 @@ } }, { - "name": "Get Feature Flags (Ephemeral Mode)", + "name": "Get Feature Flags (Deprecated - Ephemeral Mode)", "request": { "method": "POST", "header": [ @@ -227,11 +227,11 @@ "host": ["{{baseUrl}}"], "path": ["v6", "featureflag"] }, - "description": "Get feature flags using ephemeral session-only groups (ignores stored user groups). Useful for temporary group assignments without persisting to the user record." + "description": "DEPRECATED shape, kept for Java/Python client compatibility — prefer `useSingleGroupSet` below. Get feature flags using ephemeral session-only groups (ignores stored user groups). Returns a flat array of feature flag names." } }, { - "name": "Get Feature Flags (Merged Mode)", + "name": "Get Feature Flags (Deprecated - Merged Mode)", "request": { "method": "POST", "header": [ @@ -253,7 +253,85 @@ "host": ["{{baseUrl}}"], "path": ["v6", "featureflag"] }, - "description": "Get feature flags using merged mode - combines both stored user groups and session-specific groups. Both groupsForSession and includeStoredUserGroups must be provided together." + "description": "DEPRECATED shape, kept for Java/Python client compatibility — prefer `useSingleGroupSet` below. Get feature flags using merged mode - combines both stored user groups and session-specific groups. Both groupsForSession and includeStoredUserGroups must be provided together. Returns a flat array of feature flag names." + } + }, + { + "name": "Get Feature Flags (useSingleGroupSet - Ephemeral)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "User-Id", + "value": "{{userId}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": \"assignment\",\n \"useSingleGroupSet\": {\n \"groups\": {\n \"schoolId\": [\"demo-school\"],\n \"classId\": [\"demo-class-advanced\"]\n },\n \"includeStoredUserGroups\": false\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/v6/featureflag", + "host": ["{{baseUrl}}"], + "path": ["v6", "featureflag"] + }, + "description": "Get feature flags using ephemeral session-only groups (ignores stored user groups). Returns a flat array of feature flag names." + } + }, + { + "name": "Get Feature Flags (useSingleGroupSet - Merged)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "User-Id", + "value": "{{userId}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": \"assignment\",\n \"useSingleGroupSet\": {\n \"groups\": {\n \"classId\": [\"temp-class-123\", \"special-session\"]\n },\n \"includeStoredUserGroups\": true\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/v6/featureflag", + "host": ["{{baseUrl}}"], + "path": ["v6", "featureflag"] + }, + "description": "Get feature flags using merged mode - combines both stored user groups and session-specific groups into a single groupset. Returns a flat array of feature flag names." + } + }, + { + "name": "Get Feature Flags (useMultipleGroupSets)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "User-Id", + "value": "{{userId}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": \"assignment\",\n \"useMultipleGroupSets\": {\n \"mainGroupset\": {\n \"includeStoredUserGroups\": true,\n \"groups\": {}\n },\n \"subGroupsets\": [\n {\n \"groupsetId\": \"school-a\",\n \"groups\": {\n \"schoolId\": [\"school-a\"]\n },\n \"includeStoredUserGroups\": false\n },\n {\n \"groupsetId\": \"school-b\",\n \"groups\": {\n \"schoolId\": [\"school-b\"]\n },\n \"includeStoredUserGroups\": false\n }\n ]\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/v6/featureflag", + "host": ["{{baseUrl}}"], + "path": ["v6", "featureflag"] + }, + "description": "Evaluates a `mainGroupset` (the stored-user/all-groups default) plus multiple named `subGroupsets` in a single request. Response shape is `{ mainGroupset?: string[]; subGroupsets: Record }` instead of a flat array. `mainGroupset` is optional — omit it if the caller only needs the named subGroupsets." } }, { From 9eb88643fad48a62cdfb1ea5492163d72b6d66d2 Mon Sep 17 00:00:00 2001 From: doswalt Date: Tue, 15 Sep 2026 15:45:29 -0400 Subject: [PATCH 02/12] remove autogenerated ids, fix validation issues --- .../src/UpGradeClient/UpgradeClient.spec.ts | 18 ++-- .../js/src/UpGradeClient/UpgradeClient.ts | 99 ++++++++++--------- .../validators/FeatureFlagRequestValidator.ts | 30 ++++++ .../api/middlewares/UserCheckMiddleware.ts | 6 +- .../src/api/services/FeatureFlagService.ts | 56 ++++++++--- .../FeatureFlagRequestValidator.test.ts | 20 ++++ .../middlewares/UserCheckMiddleware.test.ts | 19 ++++ .../unit/services/FeatureFlagService.test.ts | 22 +++++ 8 files changed, 201 insertions(+), 69 deletions(-) diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts index 3e6cff65d5..53331e7df7 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts @@ -342,12 +342,10 @@ describe('UpgradeClient', () => { upgradeClient.setFeatureFlagGroupOptions(null); await upgradeClient.getAllFeatureFlags(); - expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({ - useSingleGroupSet: { groups: undefined, includeStoredUserGroups: undefined }, - }); + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({}); }); - it('reconfiguring with different groups naturally forces a refetch (content-addressed ids)', async () => { + it('reconfiguring with different groups forces a refetch (cache invalidated on change)', async () => { const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags().mockResolvedValueOnce(['classAFlag']); upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['classA'] } } }); @@ -392,6 +390,14 @@ describe('UpgradeClient', () => { upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: {} as any }); }).toThrow(/groups is required/); }); + + it('throws when a subGroupsets entry reuses the reserved main/single groupset id', () => { + expect(() => { + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { subGroupsets: [{ groupsetId: '*', groups: { classId: ['a'] } }] }, + }); + }).toThrow(/reserved groupset id/); + }); }); describe('#setFeatureFlagUserGroupsForSession (deprecated alias)', () => { @@ -435,9 +441,7 @@ describe('UpgradeClient', () => { const result = await upgradeClient.getAllFeatureFlags(); - expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({ - useSingleGroupSet: { groups: undefined, includeStoredUserGroups: undefined }, - }); + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({}); expect(result).toEqual(['foo']); }); diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index faba7f1d1e..6f3bc6ba19 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -184,24 +184,41 @@ export default class UpgradeClient { this.setFeatureFlagGroupOptions(featureFlagGroupOptions); } - /** Registers a single groupset's definition and returns its (auto-generated or supplied) id. */ + /** + * Registers the shared main/single groupset's definition under the fixed `DEFAULT_GROUPSET_ID` + * slot and returns that id. There's only ever one main/single groupset active at a time, so + * unlike subGroupsets entries it never needs (or accepts) a caller-supplied id. Since the id no + * longer varies with content, flags already cached under this slot are cleared whenever the + * definition actually changes, so a stale value is never served under the new definition. + */ private registerSingle(entry: UpGradeClientInterfaces.ISingleGroupSetOptions, label: string): string { if (!entry?.groups) { throw new Error(`${label}.groups is required.`); } - const id = UpgradeClient.generateGroupsetId(entry.groups, entry.includeStoredUserGroups); - this.dataService.registerGroupsetDefinition(id, { + const definition: IGroupsetDefinition = { groups: entry.groups, includeStoredUserGroups: entry.includeStoredUserGroups, - }); - return id; + }; + const previous = this.dataService.getGroupsetDefinition(DEFAULT_GROUPSET_ID); + if (previous && JSON.stringify(previous) !== JSON.stringify(definition)) { + this.dataService.clearFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID); + } + this.dataService.registerGroupsetDefinition(DEFAULT_GROUPSET_ID, definition); + return DEFAULT_GROUPSET_ID; } - /** Registers one subGroupsets entry (groupsetId is required — never auto-generated) and returns it. */ + /** + * Registers one subGroupsets entry (groupsetId is required — never auto-generated) and returns + * it. The reserved main/single slot id may not be reused here, since the two would otherwise + * collide and silently share one cached evaluation. + */ private registerSub(entry: UpGradeClientInterfaces.ISubGroupSetOptions): string { if (!entry?.groupsetId) { throw new Error('Each subGroupsets entry requires a groupsetId.'); } + if (entry.groupsetId === DEFAULT_GROUPSET_ID) { + throw new Error(`subGroupsets entry may not use the reserved groupset id "${DEFAULT_GROUPSET_ID}".`); + } if (!entry.groups) { throw new Error(`subGroupsets entry "${entry.groupsetId}" requires groups.`); } @@ -212,44 +229,17 @@ export default class UpgradeClient { return entry.groupsetId; } - /** - * Serializes a groups object into a deterministic, human-readable string: keys sorted - * alphabetically, each key's values sorted, formatted as `key:[v1,v2],key2:[v3]`. - */ - private static serializeGroups(groups: Record): string { - return Object.keys(groups) - .sort() - .map((key) => `${key}:[${[...groups[key]].sort().join(',')}]`) - .join(','); - } - - /** - * Auto-generates a composite groupset id for `useSingleGroupSet`/`mainGroupset` from its groups. - * Stored-user mode (no groups) gets `*`; merged mode gets `*,` + the serialized groups (e.g. - * `*,schoolId:[1,2]`) since stored groups are unpredictable ahead of time; ephemeral mode gets - * just the serialized groups, since no stored groups are involved at all. - */ - private static generateGroupsetId( - groups: Record | undefined, - includeStoredUserGroups: boolean | undefined - ): string { - if (!groups) { - return DEFAULT_GROUPSET_ID; - } - const serialized = UpgradeClient.serializeGroups(groups); - return includeStoredUserGroups ? `${DEFAULT_GROUPSET_ID},${serialized}` : serialized; - } - /** * Sets the active feature flag groupset configuration used when `getAllFeatureFlags()`/ * `hasFeatureFlag()` are called with no arguments. See the constructor's `featureFlagGroupOptions` * documentation for the full `useSingleGroupSet`/`useMultipleGroupSets` breakdown. * - * Note: groupset ids are content-addressed (derived from the groups themselves, unless you - * supply your own `groupsetId` for a subGroupsets entry), so reconfiguring with different groups - * naturally lands on a fresh, never-yet-fetched id — the next `getAllFeatureFlags()`/ - * `hasFeatureFlag()` call will refetch it. No explicit cache-clear is needed for that case; other - * previously-fetched groupsets are left untouched (this is additive/upsert, not a wholesale reset). + * Note: the main/single groupset always lives under one fixed internal id (subGroupsets entries + * always use their own caller-supplied `groupsetId` instead). Reconfiguring it with different + * groups clears its previously-cached flags automatically, so the next `getAllFeatureFlags()`/ + * `hasFeatureFlag()` call refetches; reconfiguring with the same groups leaves the cache as-is. + * Other previously-fetched subGroupsets are left untouched (this is additive/upsert, not a + * wholesale reset). * * @example * ```typescript @@ -690,9 +680,19 @@ export default class UpgradeClient { } const definition = this.dataService.getGroupsetDefinition(groupsetId) ?? {}; - const response = await this.apiService.getAllFeatureFlags({ - useSingleGroupSet: { groups: definition.groups, includeStoredUserGroups: definition.includeStoredUserGroups }, - }); + // definition.groups is undefined for the default (no options) groupset — omit useSingleGroupSet + // entirely in that case so the backend's plain stored-user lookup runs, rather than sending + // `useSingleGroupSet: {}`, which fails validation (groups is required on that shape). + const response = await this.apiService.getAllFeatureFlags( + definition.groups + ? { + useSingleGroupSet: { + groups: definition.groups, + includeStoredUserGroups: definition.includeStoredUserGroups, + }, + } + : {} + ); const flags = Array.isArray(response) ? response : []; this.dataService.setFeatureFlagsForGroupset(groupsetId, flags); return flags; @@ -806,9 +806,18 @@ export default class UpgradeClient { // Rehydrating a single id (whether it plays a "main" or "sub" role) is always just a plain // single-groupset evaluation — the multi-groupset framing doesn't matter for one id alone. - const response = await this.apiService.getAllFeatureFlags({ - useSingleGroupSet: { groups: definition.groups, includeStoredUserGroups: definition.includeStoredUserGroups }, - }); + // definition.groups is undefined only for the default groupset — omit useSingleGroupSet + // entirely in that case (see fetchSingle for why sending `{}` would fail validation). + const response = await this.apiService.getAllFeatureFlags( + definition.groups + ? { + useSingleGroupSet: { + groups: definition.groups, + includeStoredUserGroups: definition.includeStoredUserGroups, + }, + } + : {} + ); this.dataService.setFeatureFlagsForGroupset(groupsetId, Array.isArray(response) ? response : []); } diff --git a/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts b/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts index fa6589aaeb..cd5bd9d89c 100644 --- a/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts +++ b/packages/backend/src/api/controllers/validators/FeatureFlagRequestValidator.ts @@ -149,9 +149,39 @@ const MaxSubGroupsetsAllowed = (max: number, validationOptions?: ValidationOptio return registerMaxSubGroupsetsAllowed; }; +// Custom validation decorator to ensure `groups` matches Record — @IsObject() +// alone only validates the outer shape, so a payload like { groups: { schoolId: 'abc' } } would +// otherwise pass validation and later crash when evaluation code calls .map() on a non-array value. +const IsGroupsRecord = (validationOptions?: ValidationOptions) => { + const registerIsGroupsRecord = (object: object, propertyName: string) => { + registerDecorator({ + name: 'isGroupsRecord', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return Object.values(value).every( + (groupIds) => Array.isArray(groupIds) && groupIds.every((id) => typeof id === 'string') + ); + }, + defaultMessage() { + return 'groups must be an object whose values are each an array of strings'; + }, + }, + }); + }; + + return registerIsGroupsRecord; +}; + export class SingleGroupSetValidator { @IsNotEmpty() @IsObject() + @IsGroupsRecord() public groups: Record; /** Optional. Defaults to `false` (ephemeral) when omitted. */ diff --git a/packages/backend/src/api/middlewares/UserCheckMiddleware.ts b/packages/backend/src/api/middlewares/UserCheckMiddleware.ts index e0dafd677f..2e65431ddd 100644 --- a/packages/backend/src/api/middlewares/UserCheckMiddleware.ts +++ b/packages/backend/src/api/middlewares/UserCheckMiddleware.ts @@ -24,7 +24,11 @@ export class UserCheckMiddleware { req.logger.debug({ message: 'User Id is:', user_id }); } - if (req.url.endsWith('/v6/featureflag') && req.body?.useMultipleGroupSets) { + if ( + req.url.endsWith('/v6/featureflag') && + req.body?.useMultipleGroupSets && + Array.isArray(req.body.useMultipleGroupSets.subGroupsets) + ) { const resolved = await this.handleMultipleGroupSets(req, user_id); if (resolved === null) { const error = new Error(`User not found: ${user_id}`); diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index b139453098..eb9b719007 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -140,11 +140,11 @@ export class FeatureFlagService { * an optional mainGroupset plus one or more named subGroupsets (all belonging to the same * authenticated user — see `UserCheckMiddleware.handleMultipleGroupSets`), in one pass. * - * The cached flag list ({@link getCachedFlagsForKeys}) and precomputed-set fetch are naturally - * shared across every entry since `featureFlagLevelInclusionExclusion` itself caches per flag id, - * not per user — so this does not multiply DB/cache work by the number of groupsets. Exposure - * recording is deduped once per unique included flag across the whole batch (mainGroupset and - * subGroupsets alike), same as {@link getKeys} does for one user. + * The cached flag list ({@link getCachedFlagsForKeys}) and the precomputed-set map are fetched + * once up front and shared across every entry (passed into {@link featureFlagLevelInclusionExclusion}), + * so this does not multiply DB/cache work by the number of groupsets. Exposure recording is + * deduped once per unique included flag across the whole batch (mainGroupset and subGroupsets + * alike), same as {@link getKeys} does for one user. */ public async getKeysForMultipleGroupSets( mainDoc: RequestedExperimentUser | undefined, @@ -164,6 +164,10 @@ export class FeatureFlagService { }); const filteredFeatureFlags = await this.getCachedFlagsForKeys(context); + const precomputedMap = await this.getPrecomputedMapWithFallback( + filteredFeatureFlags.map((f) => f.id), + logger + ); const resolvedEntries = await Promise.all( entries.map(async ({ groupsetId, doc }) => { @@ -182,7 +186,13 @@ export class FeatureFlagService { throw error; } - const included = await this.featureFlagLevelInclusionExclusion(filteredFeatureFlags, doc, context, logger); + const included = await this.featureFlagLevelInclusionExclusion( + filteredFeatureFlags, + doc, + context, + logger, + precomputedMap + ); return { groupsetId, userId: doc.id, ids: included.map((f) => f.id), keys: included.map((f) => f.key) }; }) ); @@ -1176,26 +1186,40 @@ export class FeatureFlagService { return featureFlag; } + /** + * Fetches the precomputed inclusion/exclusion sets for a batch of flag ids, falling back to an + * empty map (triggering on-the-fly resolution for every flag) if the read itself fails — e.g. + * the feature_flag_precomputed_segment table isn't available yet. Callers evaluating the same + * flag ids against multiple users/groupsets in one request should call this once and reuse the + * result rather than re-fetching per entry. + */ + private async getPrecomputedMapWithFallback( + flagIds: string[], + logger: UpgradeLogger + ): Promise> { + try { + return await this.featureFlagPrecomputedSegmentService.getPrecomputedSets(flagIds); + } catch (err) { + logger.error({ + message: `getPrecomputedMapWithFallback: failed to read feature_flag_precomputed_segment; falling back to on-the-fly resolution for all flags: ${err}`, + }); + return new Map(); + } + } + private async featureFlagLevelInclusionExclusion( featureFlags: Pick[], experimentUser: ExperimentUser, context: string, - logger: UpgradeLogger + logger: UpgradeLogger, + precomputedMap?: Map ): Promise[]> { const flagIds = featureFlags.map((f) => f.id); // getPrecomputedSets can throw if the feature_flag_precomputed_segment table is unavailable // (e.g. the migration hasn't been run yet). Treat that identically to every row being missing: // swallow the error and fall through to on-the-fly segment resolution below rather than failing // the whole assignment request. Rows self-heal on the next restart (backfill) or list mutation. - let precomputedMap: Map; - try { - precomputedMap = await this.featureFlagPrecomputedSegmentService.getPrecomputedSets(flagIds); - } catch (err) { - logger.error({ - message: `featureFlagLevelInclusionExclusion: failed to read feature_flag_precomputed_segment; falling back to on-the-fly resolution for all flags: ${err}`, - }); - precomputedMap = new Map(); - } + precomputedMap ??= await this.getPrecomputedMapWithFallback(flagIds, logger); // Build type-qualified group keys from the user's group map so they match the namespaced group // IDs stored in the precomputed arrays (individuals are matched bare against experimentUser.id). diff --git a/packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts b/packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts index ee13d4ac3c..ef4bc7c225 100644 --- a/packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts +++ b/packages/backend/test/unit/controllers/validators/FeatureFlagRequestValidator.test.ts @@ -85,6 +85,26 @@ describe('FeatureFlagRequestValidator', () => { expect(errors.some((e) => e.property === 'useSingleGroupSet')).toBe(true); }); + it('fails when a groups value is a string instead of a string array', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useSingleGroupSet: { groups: { schoolId: 'demo-school' } }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useSingleGroupSet')).toBe(true); + }); + + it('fails when a groups array contains a non-string entry', async () => { + const instance = plainToInstance(FeatureFlagRequestValidator, { + context: 'test-context', + useSingleGroupSet: { groups: { schoolId: ['demo-school', 42] } }, + }); + const errors = await validate(instance, WHITELIST_OPTIONS); + + expect(errors.some((e) => e.property === 'useSingleGroupSet')).toBe(true); + }); + it('fails when combined with the deprecated top-level fields', async () => { const instance = plainToInstance(FeatureFlagRequestValidator, { context: 'test-context', diff --git a/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts b/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts index 4d5c46776c..8485f543bc 100644 --- a/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts +++ b/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts @@ -391,6 +391,25 @@ describe('UserCheckMiddleware Tests', () => { expect(mockRequest.userDocsBySubGroupset.standard).toEqual(storedUser); }); + test('defers to body validation instead of throwing when subGroupsets is missing', async () => { + const userId = 'malformed-body-user'; + const storedUser = new RequestedExperimentUser(); + storedUser.id = userId; + storedUser.requestedUserId = userId; + storedUser.group = { classId: ['stored-class'] }; + mockExperimentUserService.setMockUser(userId, storedUser); + + // Missing subGroupsets — the class-validator body validation that runs after this + // middleware is what should reject this shape (with a 400), not a TypeError thrown here. + mockRequest.body = { useMultipleGroupSets: {} }; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + expect(nextFunction).not.toHaveBeenCalledWith(expect.any(TypeError)); + }); + test('returns 404 when a non-ephemeral entry requires a stored user that does not exist', async () => { const userId = 'missing-user'; diff --git a/packages/backend/test/unit/services/FeatureFlagService.test.ts b/packages/backend/test/unit/services/FeatureFlagService.test.ts index f0bc3a6f8b..5968275a4b 100644 --- a/packages/backend/test/unit/services/FeatureFlagService.test.ts +++ b/packages/backend/test/unit/services/FeatureFlagService.test.ts @@ -1108,6 +1108,28 @@ describe('Feature Flag Service Testing', () => { expect(exposureRepo.recordExposureIfNotExists).toHaveBeenCalledTimes(1); expect(exposureRepo.recordExposureIfNotExists).toHaveBeenCalledWith([includeAllFlag.id], 'user123'); }); + + it('fetches the precomputed set map once and reuses it across every groupset entry', async () => { + const mainDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + const subDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + const precomputed = module.get(FeatureFlagPrecomputedSegmentService); + + service.cacheService.wrap = jest.fn().mockResolvedValue([includeAllFlag]); + (precomputed.getPrecomputedSets as jest.Mock).mockResolvedValue( + new Map([[includeAllFlag.id, { inclusionIds: [], exclusionIds: [] }]]) + ); + + await service.getKeysForMultipleGroupSets( + mainDoc, + { sectionA: subDoc, sectionB: subDoc, sectionC: subDoc }, + 'context1', + logger + ); + + // 1 mainGroupset + 3 subGroupsets = 4 entries evaluated, but the precomputed map should + // only be fetched once for the whole batch, not once per entry. + expect(precomputed.getPrecomputedSets).toHaveBeenCalledTimes(1); + }); }); describe('precomputed recompute + seed triggers', () => { From 15018384449a3535b6d91a2f14364f3a4ad747dc Mon Sep 17 00:00:00 2001 From: doswalt Date: Tue, 15 Sep 2026 15:51:43 -0400 Subject: [PATCH 03/12] documentation and playground app fixups --- clientlibs/js/ff-playground-app/README.md | 2 +- .../js/ff-playground-app/src/app/app.ts | 10 ++++++- .../app/feature-flag-playground.service.ts | 29 ++++++++++--------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/clientlibs/js/ff-playground-app/README.md b/clientlibs/js/ff-playground-app/README.md index f0fe13955d..c1690a5cf8 100644 --- a/clientlibs/js/ff-playground-app/README.md +++ b/clientlibs/js/ff-playground-app/README.md @@ -1,7 +1,7 @@ # Feature Flag Playground A minimal Angular app for trying out the recipes in -[`../feature-flags-guide.md`](../feature-flags-guide.md) against a real, running UpGrade backend. +[`../docs/feature-flags-guide.md`](../docs/feature-flags-guide.md) against a real, running UpGrade backend. There are only two source files: diff --git a/clientlibs/js/ff-playground-app/src/app/app.ts b/clientlibs/js/ff-playground-app/src/app/app.ts index f42f1ae280..113b212c66 100644 --- a/clientlibs/js/ff-playground-app/src/app/app.ts +++ b/clientlibs/js/ff-playground-app/src/app/app.ts @@ -114,7 +114,9 @@ function generateUUID(): string {
@for (schoolId of ff.schoolIds(); track schoolId) {
-
+
+ {{ ff.schoolCircleStates()[schoolId] ? 'ON' : 'OFF' }} +
{{ schoolId }}
} @@ -209,6 +211,12 @@ function generateUUID(): string { width: 48px; height: 48px; border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 0.65rem; } .circle.on { background: #2ecc71; diff --git a/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts index e799b56495..a8abc88be8 100644 --- a/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts +++ b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts @@ -63,13 +63,19 @@ export class FeatureFlagPlaygroundService { groups: { schoolId: [schoolId] }, })); - const featureFlagGroupOptions: UpGradeClientInterfaces.IFeatureFlagGroupOptions = { - useMultipleGroupSets: { - mainGroupset: fullGroupset, - subGroupsets: schoolSubGroupsets, - }, - // useSingleGroupSet: { groups: fullGroupset, includeStoredUserGroups: false }, // alias for current behavior - }; + // useMultipleGroupSets.subGroupsets can't be empty, so with no schoolIds this falls back to a + // plain useSingleGroupSet — still ephemeral, still one call, just nothing to fan out over. + const featureFlagGroupOptions: UpGradeClientInterfaces.IFeatureFlagGroupOptions = + schoolSubGroupsets.length > 0 + ? { + useMultipleGroupSets: { + mainGroupset: fullGroupset, + subGroupsets: schoolSubGroupsets, + }, + } + : { + useSingleGroupSet: fullGroupset, + }; // deprecated but still valid, no plans to remove: @@ -79,14 +85,11 @@ export class FeatureFlagPlaygroundService { // }; try { - 3; // ---------------------------------------------------------------------- // RECOMMENDED PATH — one groupset configuration, one network call. The // SQUARE feature reads the mainGroupset (the "whole user" view built // from everything just typed in); each school's CIRCLE reads its own - // named subGroupset. useMultipleGroupSets.subGroupsets can't be empty, - // so with no schoolIds this falls back to a plain useSingleGroupSet — - // still ephemeral, still one call, just nothing to fan out over. + // named subGroupset. // ---------------------------------------------------------------------- this.client = new UpgradeClient(userId, HOST_URL, CONTEXT, { @@ -103,7 +106,7 @@ export class FeatureFlagPlaygroundService { // backend to already have an initialized user (they'll otherwise 404). // ---------------------------------------------------------------------- // - // STORED MODE — ignores `useGroups` above entirely; uses whatever + // STORED MODE — ignores `fullGroupset` above entirely; uses whatever // groups are already stored for this user id: // // this.client = new UpgradeClient(userId, HOST_URL, CONTEXT); @@ -116,7 +119,7 @@ export class FeatureFlagPlaygroundService { // this.client = new UpgradeClient(userId, HOST_URL, CONTEXT); // await this.client.init(); // this.client.setFeatureFlagGroupOptions({ - // useSingleGroupSet: { groups: useGroups, includeStoredUserGroups: true }, + // useSingleGroupSet: { ...fullGroupset, includeStoredUserGroups: true }, // }); // One call fetches everything configured above — mainGroupset (or the From 00fecfea0e3155b4e8286129b1c850e9c8bbb14b Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:09:23 -0400 Subject: [PATCH 04/12] Refactor groupset registration and validation logic Refactor groupset registration logic to include definition comparison and clear feature flags if necessary. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- clientlibs/js/src/UpGradeClient/UpgradeClient.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index 6f3bc6ba19..a3916c032d 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -222,10 +222,15 @@ export default class UpgradeClient { if (!entry.groups) { throw new Error(`subGroupsets entry "${entry.groupsetId}" requires groups.`); } - this.dataService.registerGroupsetDefinition(entry.groupsetId, { + const definition: IGroupsetDefinition = { groups: entry.groups, includeStoredUserGroups: entry.includeStoredUserGroups, - }); + }; + const previous = this.dataService.getGroupsetDefinition(entry.groupsetId); + if (previous && JSON.stringify(previous) !== JSON.stringify(definition)) { + this.dataService.clearFeatureFlagsForGroupset(entry.groupsetId); + } + this.dataService.registerGroupsetDefinition(entry.groupsetId, definition); return entry.groupsetId; } From 75a483116875e4c811220dcaf3e9c1bc619c8f69 Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:09:49 -0400 Subject: [PATCH 05/12] Update commented code for feature flag options Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/app/feature-flag-playground.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts index a8abc88be8..6fb183c0c0 100644 --- a/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts +++ b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts @@ -80,7 +80,7 @@ export class FeatureFlagPlaygroundService { // deprecated but still valid, no plans to remove: // const featureFlagGroupOptions: UpGradeClientInterfaces.IFeatureFlagOptions = { - // groupsForSession: fullGroupset, + // groupsForSession: allGroups, // includeStoredUserGroups: true, // }; From e79bd165cdf79d36076dfa60aa42d26ea69819bc Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:10:54 -0400 Subject: [PATCH 06/12] Refactor group options registration to use feature flag Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- clientlibs/js/src/UpGradeClient/UpgradeClient.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index a3916c032d..2671bc04db 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -304,8 +304,7 @@ export default class UpgradeClient { } // A truthy but empty options object — treat the same as null (reset to default). - this.dataService.registerGroupsetDefinition(DEFAULT_GROUPSET_ID, {}); - this.activeConfig = { kind: 'single', groupsetId: DEFAULT_GROUPSET_ID }; + this.setFeatureFlagGroupOptions(null); } /** From 326ae9ee2af0207291f78b048e4590fe0a40ac5f Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:11:18 -0400 Subject: [PATCH 07/12] Clear feature flags for groupset on options null Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- clientlibs/js/src/UpGradeClient/UpgradeClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index 2671bc04db..e94e70dc37 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -275,6 +275,7 @@ export default class UpgradeClient { options: UpGradeClientInterfaces.IFeatureFlagGroupOptions | null | undefined ): void { if (options == null) { + this.dataService.clearFeatureFlagsForGroupset(DEFAULT_GROUPSET_ID); this.dataService.registerGroupsetDefinition(DEFAULT_GROUPSET_ID, {}); this.activeConfig = { kind: 'single', groupsetId: DEFAULT_GROUPSET_ID }; return; From ab33982c5b6ff89ca233179dbb48a8878bf6dacf Mon Sep 17 00:00:00 2001 From: doswalt Date: Tue, 15 Sep 2026 17:15:07 -0400 Subject: [PATCH 08/12] DRY cleanup --- .../unit/services/FeatureFlagService.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/backend/test/unit/services/FeatureFlagService.test.ts b/packages/backend/test/unit/services/FeatureFlagService.test.ts index 5968275a4b..b3ddfff4a0 100644 --- a/packages/backend/test/unit/services/FeatureFlagService.test.ts +++ b/packages/backend/test/unit/services/FeatureFlagService.test.ts @@ -1130,6 +1130,32 @@ describe('Feature Flag Service Testing', () => { // only be fetched once for the whole batch, not once per entry. expect(precomputed.getPrecomputedSets).toHaveBeenCalledTimes(1); }); + + it('resolves a subGroupsets entry whose groupsetId is the reserved-looking key "__proto__"', async () => { + const subDoc = { id: 'user123', group: {}, workingGroup: {} } as any; + const precomputed = module.get(FeatureFlagPrecomputedSegmentService); + + service.cacheService.wrap = jest.fn().mockResolvedValue([includeAllFlag]); + (precomputed.getPrecomputedSets as jest.Mock).mockResolvedValue( + new Map([[includeAllFlag.id, { inclusionIds: [], exclusionIds: [] }]]) + ); + + // Computed key, not literal `{ __proto__: subDoc }` — the literal form is special-cased by + // JS to set the prototype rather than create an own property, which would mask exactly the + // bug under test. The (now null-prototype) dictionary UserCheckMiddleware actually builds + // behaves like this computed form. + const result = await service.getKeysForMultipleGroupSets( + undefined, + { ['__proto__']: subDoc }, + 'context1', + logger + ); + + // A plain `{}` accumulator would silently drop this key (it sets the object's prototype + // instead of an own property) rather than surfacing it via Object.keys/entries. + expect(Object.keys(result.subGroupsets)).toEqual(['__proto__']); + expect(result.subGroupsets['__proto__']).toEqual([includeAllFlag.key]); + }); }); describe('precomputed recompute + seed triggers', () => { From 1dffeb3dc39b604532e5b29bb99ab78bc0a0526b Mon Sep 17 00:00:00 2001 From: doswalt Date: Wed, 16 Sep 2026 09:44:56 -0400 Subject: [PATCH 09/12] DRY some things up --- .../src/UpGradeClient/UpgradeClient.spec.ts | 172 +++++++++++++++++- .../js/src/UpGradeClient/UpgradeClient.ts | 164 ++++++++++++++--- .../api/middlewares/UserCheckMiddleware.ts | 4 +- .../src/api/services/FeatureFlagService.ts | 6 +- .../middlewares/UserCheckMiddleware.test.ts | 19 ++ 5 files changed, 334 insertions(+), 31 deletions(-) diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts index 53331e7df7..292911c8fa 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.spec.ts @@ -335,6 +335,51 @@ describe('UpgradeClient', () => { expect(result).toEqual({ subGroupsets: { sectionA: ['flag1'] } }); }); + it('reconfiguring with subGroupsets only leaves a previously-configured mainGroupset untouched', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags() + .mockResolvedValueOnce({ mainGroupset: ['mainFlag'], subGroupsets: { sectionA: ['flag1'] } }) + .mockResolvedValueOnce(['flag2']); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + }); + await upgradeClient.getAllFeatureFlags(); + + // Reconfigure with subGroupsets only — no mainGroupset key at all. + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { subGroupsets: [{ groupsetId: 'sectionB', groups: { schoolId: ['b'] } }] }, + }); + + // The previously-configured mainGroupset's cached flags are still there, reachable by its + // reserved id directly — untouched by the subs-only reconfiguration. + expect(await upgradeClient.hasFeatureFlag('mainFlag', '*')).toBe(true); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(1); + }); + + it('does not register mainGroupset when a subGroupsets entry fails validation (atomic)', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['mainFlag']); + + expect(() => { + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [ + { groupsetId: 'sectionA', groups: { schoolId: ['a'] } }, + { groups: { schoolId: ['b'] } } as any, + ], + }, + }); + }).toThrow(/requires a groupsetId/); + + // Neither the invalid call's mainGroupset nor its subGroupsets should have been committed — + // the client should still be in its original default (unconfigured) state. + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['mainFlag']); + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledWith({}); + }); + it('resetting to null restores standard stored-user lookup', async () => { mockApiServiceGetAllFeatureFlags().mockResolvedValue(['flag1']); @@ -533,10 +578,10 @@ describe('UpgradeClient', () => { }); }); - it('uses the plain useSingleGroupSet shape when only the mainGroupset needs (re)fetching', async () => { + it('an ad-hoc useSingleGroupSet override is a one-off even when its groups match the active mainGroupset', async () => { const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags() .mockResolvedValueOnce({ mainGroupset: ['flag1'], subGroupsets: { sectionA: ['flag1'] } }) - .mockResolvedValueOnce(['flag1', 'flag2']); + .mockResolvedValueOnce(['adHocFlag']); upgradeClient.setFeatureFlagGroupOptions({ useMultipleGroupSets: { @@ -546,15 +591,130 @@ describe('UpgradeClient', () => { }); await upgradeClient.getAllFeatureFlags(); - // Force a refetch of only the mainGroupset via an ad-hoc single override — sectionA stays cached. - await upgradeClient.getAllFeatureFlags({ + // Same groups as the active mainGroupset, but this is still just a one-off: it sends the + // plain useSingleGroupSet shape and does not refresh (or read from) the active mainGroupset's cache. + const adHocResult = await upgradeClient.getAllFeatureFlags({ useSingleGroupSet: { groups: { schoolId: ['a'] } }, - ignoreCache: true, }); - + expect(adHocResult).toEqual(['adHocFlag']); expect(getAllFeatureFlags).toHaveBeenNthCalledWith(2, { useSingleGroupSet: { groups: { schoolId: ['a'] }, includeStoredUserGroups: undefined }, }); + + // The active configuration's mainGroupset is untouched by the ad-hoc call above. + const activeResult = await upgradeClient.getAllFeatureFlags(); + expect(activeResult).toEqual({ mainGroupset: ['flag1'], subGroupsets: { sectionA: ['flag1'] } }); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + }); + + it('getAllFeatureFlags({ ignoreCache: true }) with no override force-refreshes the active mainGroupset', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags() + .mockResolvedValueOnce({ mainGroupset: ['flag1'], subGroupsets: { sectionA: ['flag1'] } }) + .mockResolvedValueOnce({ mainGroupset: ['flag1', 'flag2'], subGroupsets: { sectionA: ['flag1'] } }); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + }); + expect(await upgradeClient.getAllFeatureFlags()).toEqual({ + mainGroupset: ['flag1'], + subGroupsets: { sectionA: ['flag1'] }, + }); + + // This is the correct way to force-refresh the active configuration itself — no ad-hoc + // override needed. + expect(await upgradeClient.getAllFeatureFlags({ ignoreCache: true })).toEqual({ + mainGroupset: ['flag1', 'flag2'], + subGroupsets: { sectionA: ['flag1'] }, + }); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + }); + + it('an ad-hoc useSingleGroupSet call with different groups does not overwrite the active configuration', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags() + .mockResolvedValueOnce(['activeFlag']) + .mockResolvedValueOnce(['adHocFlag']); + + upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['classA'] } } }); + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['activeFlag']); + + // A one-off ad-hoc call for a *different* groupset — must not clobber what's active. + expect( + await upgradeClient.getAllFeatureFlags({ useSingleGroupSet: { groups: { classId: ['classB'] } } }) + ).toEqual(['adHocFlag']); + + // The active configuration's cached flags are untouched — no third network call. + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['activeFlag']); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + }); + + it('an ad-hoc mainGroupset with different groups does not overwrite the active main, but subs still resolve', async () => { + const getAllFeatureFlags = mockApiServiceGetAllFeatureFlags() + .mockResolvedValueOnce({ mainGroupset: ['activeMainFlag'], subGroupsets: { sectionA: ['flag1'] } }) + .mockResolvedValueOnce({ mainGroupset: ['adHocMainFlag'], subGroupsets: { sectionA: ['flag1'] } }); + + upgradeClient.setFeatureFlagGroupOptions({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + }); + await upgradeClient.getAllFeatureFlags(); + + const adHocResult = await upgradeClient.getAllFeatureFlags({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['different'] } }, + subGroupsets: [{ groupsetId: 'sectionA', groups: { schoolId: ['a'] } }], + }, + ignoreCache: true, + }); + expect(adHocResult.mainGroupset).toEqual(['adHocMainFlag']); + + // The active mainGroupset's cache is exactly what it was before the ad-hoc call. + const activeResult = await upgradeClient.getAllFeatureFlags(); + expect(activeResult).toEqual({ mainGroupset: ['activeMainFlag'], subGroupsets: { sectionA: ['flag1'] } }); + expect(getAllFeatureFlags).toHaveBeenCalledTimes(2); + }); + + it('does not register mainGroupset when an ad-hoc subGroupsets entry fails validation (atomic)', async () => { + mockApiServiceGetAllFeatureFlags().mockResolvedValue(['activeFlag']); + upgradeClient.setFeatureFlagGroupOptions({ useSingleGroupSet: { groups: { classId: ['classA'] } } }); + + await expect( + upgradeClient.getAllFeatureFlags({ + useMultipleGroupSets: { + mainGroupset: { groups: { schoolId: ['a'] } }, + subGroupsets: [ + { groupsetId: 'valid', groups: { schoolId: ['a'] } }, + { groupsetId: '*', groups: {} } as any, + ], + }, + }) + ).rejects.toThrow(/reserved groupset id/); + + // The active useSingleGroupSet configuration must be unaffected by the failed ad-hoc call. + expect(await upgradeClient.getAllFeatureFlags()).toEqual(['activeFlag']); + expect(ApiService.prototype.getAllFeatureFlags).toHaveBeenCalledTimes(1); + }); + + it('preserves a subGroupsets entry with the reserved-looking key "__proto__"', async () => { + // Computed key, not literal `{ __proto__: [...] }` — the literal form is special-cased by JS + // to set the prototype rather than create an own property, which would mask exactly the bug + // under test. A real wire response parsed via JSON.parse behaves like the computed form. + mockApiServiceGetAllFeatureFlags().mockResolvedValue({ + mainGroupset: undefined, + subGroupsets: { ['__proto__']: ['flag1'] }, + }); + + const result = (await upgradeClient.getAllFeatureFlags({ + useMultipleGroupSets: { subGroupsets: [{ groupsetId: '__proto__', groups: { schoolId: ['a'] } }] }, + })) as any; + + expect(Object.keys(result.subGroupsets)).toEqual(['__proto__']); + expect(result.subGroupsets['__proto__']).toEqual(['flag1']); + expect(Object.getPrototypeOf(result.subGroupsets)).toBeNull(); }); }); diff --git a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts index e94e70dc37..f4feb2e628 100644 --- a/clientlibs/js/src/UpGradeClient/UpgradeClient.ts +++ b/clientlibs/js/src/UpGradeClient/UpgradeClient.ts @@ -184,6 +184,35 @@ export default class UpgradeClient { this.setFeatureFlagGroupOptions(featureFlagGroupOptions); } + /** + * Validates a single/main groupset entry without mutating anything. Used both by + * `registerSingle` and to pre-validate a whole `useMultipleGroupSets` batch up front, so a bad + * `subGroupsets` entry can't leave an already-registered `mainGroupset` behind (see + * `assertValidSubEntry`'s doc for the matching subGroupsets concern). + */ + private static assertValidSingleEntry(entry: UpGradeClientInterfaces.ISingleGroupSetOptions, label: string): void { + if (!entry?.groups) { + throw new Error(`${label}.groups is required.`); + } + } + + /** + * Validates one subGroupsets entry without mutating anything. Used both by `registerSub` and to + * pre-validate a whole `useMultipleGroupSets.subGroupsets` array up front — so that, say, the + * 5th entry failing validation can't leave the first four already registered. + */ + private static assertValidSubEntry(entry: UpGradeClientInterfaces.ISubGroupSetOptions): void { + if (!entry?.groupsetId) { + throw new Error('Each subGroupsets entry requires a groupsetId.'); + } + if (entry.groupsetId === DEFAULT_GROUPSET_ID) { + throw new Error(`subGroupsets entry may not use the reserved groupset id "${DEFAULT_GROUPSET_ID}".`); + } + if (!entry.groups) { + throw new Error(`subGroupsets entry "${entry.groupsetId}" requires groups.`); + } + } + /** * Registers the shared main/single groupset's definition under the fixed `DEFAULT_GROUPSET_ID` * slot and returns that id. There's only ever one main/single groupset active at a time, so @@ -192,9 +221,7 @@ export default class UpgradeClient { * definition actually changes, so a stale value is never served under the new definition. */ private registerSingle(entry: UpGradeClientInterfaces.ISingleGroupSetOptions, label: string): string { - if (!entry?.groups) { - throw new Error(`${label}.groups is required.`); - } + UpgradeClient.assertValidSingleEntry(entry, label); const definition: IGroupsetDefinition = { groups: entry.groups, includeStoredUserGroups: entry.includeStoredUserGroups, @@ -213,15 +240,7 @@ export default class UpgradeClient { * collide and silently share one cached evaluation. */ private registerSub(entry: UpGradeClientInterfaces.ISubGroupSetOptions): string { - if (!entry?.groupsetId) { - throw new Error('Each subGroupsets entry requires a groupsetId.'); - } - if (entry.groupsetId === DEFAULT_GROUPSET_ID) { - throw new Error(`subGroupsets entry may not use the reserved groupset id "${DEFAULT_GROUPSET_ID}".`); - } - if (!entry.groups) { - throw new Error(`subGroupsets entry "${entry.groupsetId}" requires groups.`); - } + UpgradeClient.assertValidSubEntry(entry); const definition: IGroupsetDefinition = { groups: entry.groups, includeStoredUserGroups: entry.includeStoredUserGroups, @@ -244,7 +263,13 @@ export default class UpgradeClient { * groups clears its previously-cached flags automatically, so the next `getAllFeatureFlags()`/ * `hasFeatureFlag()` call refetches; reconfiguring with the same groups leaves the cache as-is. * Other previously-fetched subGroupsets are left untouched (this is additive/upsert, not a - * wholesale reset). + * wholesale reset) — in particular, reconfiguring with `useMultipleGroupSets.subGroupsets` only + * (no `mainGroupset`) leaves whatever main/single groupset was previously configured completely + * as-is, definition and cache included. + * + * A `useMultipleGroupSets` call validates every entry (`mainGroupset` and all of `subGroupsets`) + * before registering any of them, so a call that ends up throwing never partially applies — + * whatever was configured before the call remains in effect. * * @example * ```typescript @@ -292,6 +317,14 @@ export default class UpgradeClient { if (!subGroupsets || subGroupsets.length === 0) { throw new Error('useMultipleGroupSets.subGroupsets must contain at least one entry.'); } + // Validate the whole batch before registering anything, so a later invalid entry can't + // leave an earlier one already committed (e.g. mainGroupset registered, then a bad + // subGroupsets entry throws) while this call as a whole still fails. + if (mainGroupset) { + UpgradeClient.assertValidSingleEntry(mainGroupset, 'mainGroupset'); + } + subGroupsets.forEach((entry) => UpgradeClient.assertValidSubEntry(entry)); + const mainGroupsetId = mainGroupset ? this.registerSingle(mainGroupset, 'mainGroupset') : null; const subGroupsetIds = subGroupsets.map((entry) => this.registerSub(entry)); this.activeConfig = { kind: 'multiple', mainGroupsetId, subGroupsetIds }; @@ -631,9 +664,15 @@ export default class UpgradeClient { * from cache; cache misses are aggregated into a single batched request. * * Passing `useSingleGroupSet`/`useMultipleGroupSets` directly evaluates that ad-hoc configuration - * for this call only, without touching the active configuration — the same shape-in/shape-out - * rule applies. `ignoreCache: true` forces a refetch of whatever's relevant to this call, upserted - * into the cache; everything else already fetched is left untouched. + * for this call only, without touching the active configuration: the main/single groupset portion + * (`useSingleGroupSet`, or `useMultipleGroupSets.mainGroupset`) is always evaluated fresh and never + * cached, so it can never change what's active. subGroupsets keep their normal shared, cacheable + * identity either way (an ad-hoc subGroupsets entry updates the same cache a matching active + * subGroupsets entry would read) — `ignoreCache: true` forces a refetch of any that are already + * cached, upserted into the cache; everything else already fetched is left untouched. + * + * To force-refresh the active configuration itself, pass `ignoreCache: true` with no override, + * rather than passing an ad-hoc override with matching groups. * * @example * ```typescript @@ -660,14 +699,33 @@ export default class UpgradeClient { if (!subGroupsets || subGroupsets.length === 0) { throw new Error('useMultipleGroupSets.subGroupsets must contain at least one entry.'); } - const mainGroupsetId = mainGroupset ? this.registerSingle(mainGroupset, 'mainGroupset') : null; + // Validate the whole batch before registering anything — see the matching comment in + // setFeatureFlagGroupOptions for why. + if (mainGroupset) { + UpgradeClient.assertValidSingleEntry(mainGroupset, 'mainGroupset'); + } + subGroupsets.forEach((entry) => UpgradeClient.assertValidSubEntry(entry)); + const subGroupsetIds = subGroupsets.map((entry) => this.registerSub(entry)); - return this.fetchMultiple(mainGroupsetId, subGroupsetIds, ignoreCache); + if (!mainGroupset) { + return this.fetchMultiple(null, subGroupsetIds, ignoreCache); + } + // An ad-hoc mainGroupset is evaluated fresh, for this call only — see fetchAdHocWithMain. + return this.fetchAdHocWithMain(mainGroupset, subGroupsetIds, ignoreCache); } if (useSingleGroupSet) { - const groupsetId = this.registerSingle(useSingleGroupSet, 'useSingleGroupSet'); - return this.fetchSingle(groupsetId, ignoreCache); + UpgradeClient.assertValidSingleEntry(useSingleGroupSet, 'useSingleGroupSet'); + // Ad-hoc: evaluated directly against the API for this call only. Unlike setFeatureFlagGroupOptions's + // useSingleGroupSet, this never persists a definition or caches anything — it can't affect, + // or be affected by, the active configuration's main/single slot. + const response = await this.apiService.getAllFeatureFlags({ + useSingleGroupSet: { + groups: useSingleGroupSet.groups, + includeStoredUserGroups: useSingleGroupSet.includeStoredUserGroups, + }, + }); + return Array.isArray(response) ? response : []; } if (this.activeConfig.kind === 'single') { @@ -752,14 +810,74 @@ export default class UpgradeClient { } } - const result: UpGradeClientInterfaces.IMultiGroupSetFeatureFlagsResult = { subGroupsets: {} }; + const result: UpGradeClientInterfaces.IMultiGroupSetFeatureFlagsResult = { + subGroupsets: this.buildSubGroupsetsResult(subGroupsetIds), + }; if (mainGroupsetId) { result.mainGroupset = this.dataService.getFeatureFlagsForGroupset(mainGroupsetId) ?? []; } + return result; + } + + /** + * Evaluates an ad-hoc `useMultipleGroupSets` call that includes a `mainGroupset`. The main + * portion is always fetched fresh and never cached — ad-hoc main/single groupsets are one-off, + * evaluated for this call only (see `getAllFeatureFlags`'s docs) — while subGroupsets keep their + * normal shared, cacheable identity, so this still batches the mainGroupset together with + * whichever subGroupsets actually need (re)fetching, in one request when there are any. + * + * Precondition: `mainGroupset` has already been validated by the caller (`getAllFeatureFlags`). + */ + private async fetchAdHocWithMain( + mainGroupset: UpGradeClientInterfaces.ISingleGroupSetOptions, + subGroupsetIds: string[], + ignoreCache: boolean + ): Promise { + const mainOptions: UpGradeClientInterfaces.ISingleGroupSetOptions = { + groups: mainGroupset.groups, + includeStoredUserGroups: mainGroupset.includeStoredUserGroups, + }; + const subIdsToFetch = ignoreCache + ? subGroupsetIds + : subGroupsetIds.filter((id) => this.dataService.getFeatureFlagsForGroupset(id) == null); + + let mainFlags: string[]; + if (subIdsToFetch.length === 0) { + // No subGroupsets need fetching — the backend requires useMultipleGroupSets.subGroupsets to + // be non-empty, so fetch the mainGroupset alone via the plain single-groupset shape. + const response = await this.apiService.getAllFeatureFlags({ useSingleGroupSet: mainOptions }); + mainFlags = Array.isArray(response) ? response : []; + } else { + const response = await this.apiService.getAllFeatureFlags({ + useMultipleGroupSets: { + mainGroupset: mainOptions, + subGroupsets: subIdsToFetch.map( + (id) => + ({ + groupsetId: id, + ...(this.dataService.getGroupsetDefinition(id) ?? {}), + } as UpGradeClientInterfaces.ISubGroupSetOptions) + ), + }, + }); + mainFlags = response && !Array.isArray(response) ? response.mainGroupset ?? [] : []; + if (response && !Array.isArray(response)) { + this.dataService.setFeatureFlagsForGroupsets(response.subGroupsets ?? {}); + } + } + + return { mainGroupset: mainFlags, subGroupsets: this.buildSubGroupsetsResult(subGroupsetIds) }; + } + + /** A null-prototype dictionary of each id's cached flags — a caller-supplied groupsetId of + * "__proto__" would otherwise set the object's prototype instead of an own property, silently + * dropping that entry. */ + private buildSubGroupsetsResult(subGroupsetIds: string[]): Record { + const subGroupsets: Record = Object.create(null); subGroupsetIds.forEach((id) => { - result.subGroupsets[id] = this.dataService.getFeatureFlagsForGroupset(id) ?? []; + subGroupsets[id] = this.dataService.getFeatureFlagsForGroupset(id) ?? []; }); - return result; + return subGroupsets; } /** diff --git a/packages/backend/src/api/middlewares/UserCheckMiddleware.ts b/packages/backend/src/api/middlewares/UserCheckMiddleware.ts index 2e65431ddd..f25fa9123d 100644 --- a/packages/backend/src/api/middlewares/UserCheckMiddleware.ts +++ b/packages/backend/src/api/middlewares/UserCheckMiddleware.ts @@ -222,7 +222,9 @@ export class UserCheckMiddleware { }; const main = mainGroupset ? resolveOne(mainGroupset) : undefined; - const subGroupsetsResolved: Record = {}; + // A null-prototype dictionary — a caller-supplied groupsetId of "__proto__" would otherwise + // set this object's prototype instead of an own property, silently dropping that entry. + const subGroupsetsResolved: Record = Object.create(null); subGroupsets.forEach((entry) => { subGroupsetsResolved[entry.groupsetId] = resolveOne(entry); }); diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index eb9b719007..f20667c8cc 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -197,7 +197,11 @@ export class FeatureFlagService { }) ); - const result: { mainGroupset?: string[]; subGroupsets: Record } = { subGroupsets: {} }; + // A null-prototype dictionary — a caller-supplied groupsetId of "__proto__" would otherwise + // set this object's prototype instead of an own property, silently dropping that entry. + const result: { mainGroupset?: string[]; subGroupsets: Record } = { + subGroupsets: Object.create(null), + }; resolvedEntries.forEach(({ groupsetId, keys }) => { if (groupsetId === undefined) { result.mainGroupset = keys; diff --git a/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts b/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts index 8485f543bc..11813f0aa6 100644 --- a/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts +++ b/packages/backend/test/unit/middlewares/UserCheckMiddleware.test.ts @@ -410,6 +410,25 @@ describe('UserCheckMiddleware Tests', () => { expect(nextFunction).not.toHaveBeenCalledWith(expect.any(TypeError)); }); + test('resolves a subGroupsets entry whose groupsetId is the reserved-looking key "__proto__"', async () => { + const userId = 'ephemeral-user-proto'; + + mockRequest.body = { + useMultipleGroupSets: { + subGroupsets: [{ groupsetId: '__proto__', groups: { classId: ['classA'] } }], + }, + }; + (mockRequest.get as jest.Mock).mockReturnValue(userId); + + await middleware.use(mockRequest as AppRequest, mockResponse, nextFunction); + + expect(nextFunction).toHaveBeenCalledWith(); + // A plain `{}` accumulator would silently drop this key (it sets the object's prototype + // instead of an own property) rather than surfacing it via Object.keys/entries. + expect(Object.keys(mockRequest.userDocsBySubGroupset)).toEqual(['__proto__']); + expect(mockRequest.userDocsBySubGroupset['__proto__'].group).toEqual({ classId: ['classA'] }); + }); + test('returns 404 when a non-ephemeral entry requires a stored user that does not exist', async () => { const userId = 'missing-user'; From 8af5d91cf41e40cb7bff128a99496f52f860472b Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:57:49 -0400 Subject: [PATCH 10/12] Add required field 'groups' to useSingleGroupSet Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/api/controllers/ExperimentClientController.v6.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/api/controllers/ExperimentClientController.v6.ts b/packages/backend/src/api/controllers/ExperimentClientController.v6.ts index 06da9a7483..8303aea2e8 100644 --- a/packages/backend/src/api/controllers/ExperimentClientController.v6.ts +++ b/packages/backend/src/api/controllers/ExperimentClientController.v6.ts @@ -707,6 +707,8 @@ export class ExperimentClientController { * description: The context for feature flag evaluation * useSingleGroupSet: * type: object + * required: + * - groups * properties: * groups: * type: object From ca577e4587ccd534f9ade00c4cdbecc8f5cd999d Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:58:31 -0400 Subject: [PATCH 11/12] Enhance remove button with aria-label Updated remove button to include aria-label for accessibility. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- clientlibs/js/ff-playground-app/src/app/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clientlibs/js/ff-playground-app/src/app/app.ts b/clientlibs/js/ff-playground-app/src/app/app.ts index 113b212c66..2a75bc3047 100644 --- a/clientlibs/js/ff-playground-app/src/app/app.ts +++ b/clientlibs/js/ff-playground-app/src/app/app.ts @@ -76,7 +76,7 @@ function generateUUID(): string { (input)="setRowValues($index, input($event))" placeholder="comma-separated values, e.g. school-1, school-2" /> - +
} From 81d77f4949cbfc17e19342a1f0fe95f486717544 Mon Sep 17 00:00:00 2001 From: danoswaltCL <97542869+danoswaltCL@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:58:53 -0400 Subject: [PATCH 12/12] Update allGroups to maintain unique values Ensure unique values are stored in allGroups for each type. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/app/feature-flag-playground.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts index 6fb183c0c0..1271920855 100644 --- a/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts +++ b/clientlibs/js/ff-playground-app/src/app/feature-flag-playground.service.ts @@ -53,7 +53,7 @@ export class FeatureFlagPlaygroundService { const allGroups: Record = {}; for (const { type, values } of groups) { if (values.length) { - allGroups[type] = values; + allGroups[type] = [...new Set([...(allGroups[type] ?? []), ...values])]; } } const schoolIds = allGroups['schoolId'] ?? [];