@metabase/cli 0.2.1-alpha.bcm-ghy-4075-spawn-default-profile.eb120d1 → 0.2.1-alpha.trust-system-ca.541e4e0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -69
- package/dist/{add-collection-CLXfrgp4.mjs → add-collection-BZAVH5Wk.mjs} +4 -4
- package/dist/add-collection-DByJYvGO.mjs +9 -0
- package/dist/{append-DMcxP1LM.mjs → append-Cak5jykn.mjs} +5 -5
- package/dist/{archive-DErhQn8A.mjs → archive-BIEnBoE8.mjs} +4 -4
- package/dist/{archive-BwIZzWGe.mjs → archive-BMbx6lt8.mjs} +3 -3
- package/dist/{archive-h-3H5b4K.mjs → archive-BVY_jD_g.mjs} +5 -5
- package/dist/{archive-4WqMsPWB.mjs → archive-CPgWNCG3.mjs} +4 -4
- package/dist/{archive-Q1387APe.mjs → archive-CaKhG58H.mjs} +3 -3
- package/dist/{archive-BggM_AuL.mjs → archive-CjnCAM61.mjs} +5 -5
- package/dist/{archive-DTnz1suV.mjs → archive-CytCvQpn.mjs} +4 -4
- package/dist/{archive-BmhehbS0.mjs → archive-DfbI8DRB.mjs} +3 -3
- package/dist/{archive-5inmbMDL.mjs → archive-op9pJRpg.mjs} +3 -3
- package/dist/{auth-DzCAiArV.mjs → auth-XfuGXI2K.mjs} +4 -4
- package/dist/{body-5snfmiwR.mjs → body-BW_CiL1d.mjs} +3 -3
- package/dist/{branches-PL170CYS.mjs → branches-gVRwKne4.mjs} +4 -4
- package/dist/{cancel-EFKcF5J_.mjs → cancel-CBOJSslz.mjs} +3 -3
- package/dist/{cancel-task-mFFx0d5m.mjs → cancel-task-Djancczz.mjs} +4 -4
- package/dist/{capabilities-CiaAfUgX.mjs → capabilities-CSjeWyLV.mjs} +1 -1
- package/dist/{card-BsfdXK0O.mjs → card-B_kiDDil.mjs} +6 -6
- package/dist/{cards-BI7zKb9M.mjs → cards-23FtfL4S.mjs} +3 -3
- package/dist/cli.mjs +41 -29
- package/dist/{collection-2h_by9Ue.mjs → collection-DNzM3u7n.mjs} +6 -6
- package/dist/{collection-namespace-uNA5WhLB.mjs → collection-namespace-w1y5D0Uh.mjs} +2 -2
- package/dist/{create-BLD0YniB.mjs → create-BRK8TBzp.mjs} +3 -3
- package/dist/{create-CU3bYpsT.mjs → create-BkvdOecg.mjs} +5 -5
- package/dist/{create-FuZa4j1N.mjs → create-CGsTL5aE.mjs} +5 -5
- package/dist/{create-CQ32FwVe.mjs → create-C_fyIHZU.mjs} +5 -5
- package/dist/{create-xPfmI1Tu.mjs → create-CnT-L03B.mjs} +7 -7
- package/dist/{create-CBKT7SE_.mjs → create-CnqGhtuF.mjs} +4 -4
- package/dist/{create-VrSowEbj.mjs → create-CvptjYVb.mjs} +6 -6
- package/dist/{create-DAH24ltu.mjs → create-DZBtbGqj.mjs} +5 -5
- package/dist/{create-KveE3j3p.mjs → create-D_eiLBqR.mjs} +8 -8
- package/dist/{create-BxCsAdqc.mjs → create-DbHIjWio.mjs} +5 -5
- package/dist/{create-BpizGzgQ.mjs → create-VA58AF6A.mjs} +5 -5
- package/dist/{create-branch-CP1Y4_El.mjs → create-branch-BtqOfSRb.mjs} +4 -4
- package/dist/{create-acWKw4Y1.mjs → create-ldvT5L0W.mjs} +6 -6
- package/dist/{create-DutkaD7P.mjs → create-q2QqkwEN.mjs} +7 -7
- package/dist/{csv-CZyYi0vi.mjs → csv-C3O1wTQ8.mjs} +5 -5
- package/dist/{current-task-BR4koo9z.mjs → current-task-CZsZPe0o.mjs} +4 -4
- package/dist/{dashboard-ARgJfamb.mjs → dashboard-kb1oz7Z3.mjs} +8 -8
- package/dist/{db-dbnxMPSR.mjs → db-CHnqeJCo.mjs} +6 -6
- package/dist/{delete-BpL3vaaT.mjs → delete-BmT5C6eK.mjs} +5 -5
- package/dist/{delete-DCDFLHFa.mjs → delete-DgI03U43.mjs} +5 -5
- package/dist/{delete-P2Qlsr_s.mjs → delete-Dkfu4e5K.mjs} +5 -5
- package/dist/{delete-C5DqDwev.mjs → delete-JeRzVISE.mjs} +5 -5
- package/dist/{delete-ea_ns1uM.mjs → delete-cF97pfFX.mjs} +5 -5
- package/dist/{delete-runtime-DdBn6poA.mjs → delete-runtime-DiqKOEqi.mjs} +2 -2
- package/dist/{delete-table-tM048btr.mjs → delete-table-dnQs_Twm.mjs} +5 -5
- package/dist/{dependencies-DdOTClO2.mjs → dependencies-BsMwFSxP.mjs} +4 -4
- package/dist/{dirty-CEQYmhNF.mjs → dirty-CN6sGKX3.mjs} +4 -4
- package/dist/{document-DR1FLOn9.mjs → document-Y_DZNi4D.mjs} +5 -5
- package/dist/{eid-CtKGjAkz.mjs → eid-60sCZhQV.mjs} +4 -4
- package/dist/{events-C26DZ6yZ.mjs → events-CSZEAgs0.mjs} +4 -4
- package/dist/{export-CippuWZJ.mjs → export-CAxUJer6.mjs} +8 -5
- package/dist/{field-CXDndCbB.mjs → field-DAB2gImv.mjs} +4 -4
- package/dist/{fields-Xm_HVNcX.mjs → fields-CLWtUlpR.mjs} +3 -3
- package/dist/{get-DTkvGu_J.mjs → get-B22wjDxr.mjs} +3 -3
- package/dist/{get-B90bXbpw.mjs → get-BIHtWl2x.mjs} +4 -4
- package/dist/{get-BQz45IBF.mjs → get-BNBRMNtC.mjs} +3 -3
- package/dist/{get-B0cq1uGy.mjs → get-B_EinsrO.mjs} +4 -4
- package/dist/{get-CjZWjRa5.mjs → get-B_QKP1vR.mjs} +4 -4
- package/dist/{get-D5crrFSf.mjs → get-CKnHNxak.mjs} +3 -3
- package/dist/{get-Bj66bcuU.mjs → get-CRXEqpar.mjs} +4 -4
- package/dist/{get-BrKqMM1d.mjs → get-Ca30K5_W.mjs} +4 -4
- package/dist/{get-Ihx_XaaI.mjs → get-CaosV7j8.mjs} +3 -3
- package/dist/{get-CwGqARAf.mjs → get-Csa7Dz36.mjs} +3 -3
- package/dist/{get-BQBu2c94.mjs → get-Ct5TnofS.mjs} +3 -3
- package/dist/{get-z0BKkiRX.mjs → get-DTjeTNGb.mjs} +4 -4
- package/dist/{get-BSfKWZNJ.mjs → get-DU8V-7S2.mjs} +4 -4
- package/dist/{get-DYuJVlP-.mjs → get-DxAx-2C0.mjs} +4 -4
- package/dist/{get-FIE88hj9.mjs → get-H3X5e5-Y.mjs} +3 -3
- package/dist/{get-DX2fp6BB.mjs → get-L8c_h5EP.mjs} +3 -3
- package/dist/{get-3O7hv4zv.mjs → get-fGOQA0GF.mjs} +3 -3
- package/dist/{get-run-lVwhQVFx.mjs → get-run-DqwPQRMJ.mjs} +4 -4
- package/dist/git-sync-BhhdNjq5.mjs +31 -0
- package/dist/{has-remote-changes-DHrYSnTg.mjs → has-remote-changes-D0ZIBHSk.mjs} +4 -4
- package/dist/{import-BU40xXSF.mjs → import-BcIcDPl9.mjs} +6 -6
- package/dist/{input-BGyHn0Kr.mjs → input-D2-H-QKX.mjs} +1 -1
- package/dist/{is-dirty-BPluqPPP.mjs → is-dirty-B87YMLV2.mjs} +3 -3
- package/dist/is-dirty-CXTS25Tk.mjs +8 -0
- package/dist/{items-BZpYkImP.mjs → items-DrP1b4Qj.mjs} +4 -4
- package/dist/{key-B8UAsQmB.mjs → key-GP1gxZZD.mjs} +1 -1
- package/dist/{library-BGWDIfEM.mjs → library-bUBWlweP.mjs} +4 -4
- package/dist/{list-Ov6bYzT9.mjs → list-B-_PTqKw.mjs} +3 -3
- package/dist/{list-M_1sdYNH.mjs → list-B8212eUr.mjs} +3 -3
- package/dist/{list-Be8xMtS3.mjs → list-B8zgT05M.mjs} +3 -3
- package/dist/{list-CoqHnusK.mjs → list-BhCahBEO.mjs} +3 -3
- package/dist/{list-CjBPVJqd.mjs → list-CWQYaCp8.mjs} +3 -3
- package/dist/{list-BCeZlfpQ.mjs → list-Cbhx7N_t.mjs} +3 -3
- package/dist/{list-B7Ihu6XI.mjs → list-CoXeBm66.mjs} +3 -3
- package/dist/{list-CvaoHnl-.mjs → list-D1pHJX7w.mjs} +3 -3
- package/dist/{list-Bct00qku.mjs → list-DCl7zHCp.mjs} +2 -2
- package/dist/{list-DmopauvJ.mjs → list-DhT42e0q.mjs} +2 -2
- package/dist/{list-wxIVl45_.mjs → list-F_hMRbsa.mjs} +3 -3
- package/dist/{list-sn9M7QQX.mjs → list-GeqRHx8g.mjs} +3 -3
- package/dist/{list-SfpASSFx.mjs → list-MPePXEWe.mjs} +3 -3
- package/dist/{list-DamRE5Ds.mjs → list-TJjExx6e.mjs} +3 -3
- package/dist/{list-B037kJtH.mjs → list-TQghfc-u.mjs} +3 -3
- package/dist/{list-fPfgG0PY.mjs → list-TibX1b8i.mjs} +3 -3
- package/dist/{login-CRlmR4op.mjs → login-C5Ug3BVC.mjs} +19 -59
- package/dist/{logout-DSZEUxSQ.mjs → logout-B_N1N2DV.mjs} +4 -4
- package/dist/{measure-Ka0n3426.mjs → measure-D9rKeA6Z.mjs} +5 -5
- package/dist/{parameter-values-CwW8Xes7.mjs → parameter-values-ZXKECrJk.mjs} +3 -3
- package/dist/{parse-enum-ooCsJWn6.mjs → parse-enum-D5h-JePs.mjs} +1 -1
- package/dist/parse-id-ySsF4nA3.mjs +12 -0
- package/dist/{parse-ref-j4lxaQmd.mjs → parse-ref-Ds8VxdKb.mjs} +1 -1
- package/dist/{path-CVmg-bmf.mjs → path-C6ucgeYs.mjs} +3 -3
- package/dist/{poll-CuTlWBwI.mjs → poll-CgzD9_RH.mjs} +2 -2
- package/dist/{poll-task-DhsYs8Hx.mjs → poll-task-DF9xJl13.mjs} +2 -2
- package/dist/{preflight-D3VgjEg7.mjs → preflight-Co2grgX_.mjs} +2 -2
- package/dist/{process-7c8BcQEi.mjs → process-jjR_DX5G.mjs} +1 -1
- package/dist/{prompt-jQnNKtlx.mjs → prompt-BSqji7Fo.mjs} +1 -1
- package/dist/{publish-DFTCsjHU.mjs → publish-F-QbufXr.mjs} +8 -8
- package/dist/{query-CPq-dhFV.mjs → query-DGBzATpV.mjs} +7 -7
- package/dist/{query-Dfx69iJ4.mjs → query-DX4r0-uz.mjs} +4 -4
- package/dist/{query-result-Cg7odyR_.mjs → query-result-D5J3Dlpn.mjs} +1 -1
- package/dist/{remove-collection-DUAMhx5L.mjs → remove-collection-C-msSyas.mjs} +6 -6
- package/dist/{replace-DZWd6avZ.mjs → replace-xlQv9yl_.mjs} +5 -5
- package/dist/{requests-DYrDe-D0.mjs → requests-COuEPJxE.mjs} +2 -2
- package/dist/{rescan-values-DZSnEFA0.mjs → rescan-values-BC2SVtY6.mjs} +3 -3
- package/dist/{run-ryZ4sXK5.mjs → run-Cg6k3kHo.mjs} +6 -6
- package/dist/{run-B2b3Bjmv.mjs → run-opQkYIaT.mjs} +3 -3
- package/dist/{runs-F82HVSie.mjs → runs-Cbqm1HYG.mjs} +4 -4
- package/dist/{runtime-C6ASrgVA.mjs → runtime-MGH96fhp.mjs} +1056 -1092
- package/dist/{schema-tables-CCOKb715.mjs → schema-tables-8FjtkFIt.mjs} +3 -3
- package/dist/{schemas-LtVrNECH.mjs → schemas-L9kMsEJL.mjs} +3 -3
- package/dist/{search-9GSZHgJQ.mjs → search-Csbb-u-_.mjs} +3 -3
- package/dist/{segment-C40bORNZ.mjs → segment-DCeafJwX.mjs} +5 -5
- package/dist/{selectors-B2nDmrIV.mjs → selectors-D34gXjQa.mjs} +3 -3
- package/dist/{set-C57KDMGM.mjs → set-CfwncOI_.mjs} +6 -6
- package/dist/{set-active-BDHGHtj2.mjs → set-active-gP4mjVDY.mjs} +3 -3
- package/dist/{setting-zrJwy48C.mjs → setting-D5Y9sJha.mjs} +3 -3
- package/dist/{setup-Doq8atTG.mjs → setup-D9HNfS7W.mjs} +4 -4
- package/dist/{skills-yuzH_o8s.mjs → skills-DvSQOPWg.mjs} +3 -3
- package/dist/{skills-y-KsUqT9.mjs → skills-aRQfUvCq.mjs} +1 -1
- package/dist/{snippet-ScFM8re8.mjs → snippet-CwRiMCfE.mjs} +5 -5
- package/dist/{stash-D_u6z4q6.mjs → stash-C_k1uZlf.mjs} +6 -6
- package/dist/{status-CDPhDX5I.mjs → status-BsEPyknG.mjs} +6 -6
- package/dist/{status--Dm2Jv6k.mjs → status-D1vtL5eW.mjs} +3 -3
- package/dist/{summary-C9a2cBTE.mjs → summary-h20AjhcO.mjs} +3 -3
- package/dist/{sync-schema-CDIWNCGI.mjs → sync-schema-D67cYLhd.mjs} +5 -5
- package/dist/{sync-scope-DW2QpGwv.mjs → sync-scope-CKx6xeF6.mjs} +3 -3
- package/dist/{table-BKehBm_E.mjs → table-CmKFWPvJ.mjs} +4 -4
- package/dist/{timeline-n3Nw9Egt.mjs → timeline-1ZphX3zj.mjs} +7 -7
- package/dist/{timeline-event-CLWMxvE0.mjs → timeline-event-C6aKZ4EO.mjs} +5 -5
- package/dist/transform-CvsBOTqB.mjs +31 -0
- package/dist/transform-job-D_8Kp772.mjs +25 -0
- package/dist/{transform-tag-Bkyasy_s.mjs → transform-tag-DhDnvbmq.mjs} +4 -4
- package/dist/{transforms-w2a5qsjJ.mjs → transforms-BQkmsPUR.mjs} +4 -4
- package/dist/{tree-JZC6vD9q.mjs → tree-CtufTBZY.mjs} +2 -2
- package/dist/{unpublish-BsfIH46y.mjs → unpublish-CnLXPYR8.mjs} +4 -4
- package/dist/{update-Cg09lBb5.mjs → update-BC-MLtiG.mjs} +9 -9
- package/dist/{update-B8hn6VxO.mjs → update-BZxLyBVm.mjs} +8 -8
- package/dist/{update-DSlSr7Ky.mjs → update-C4P_YtGW.mjs} +6 -6
- package/dist/{update-BSNK36fk.mjs → update-CE-Z6E8V.mjs} +7 -7
- package/dist/{update-D01S8hnw.mjs → update-CqQj32ol.mjs} +6 -6
- package/dist/{update-BTFj6WHo.mjs → update-D6blsNGY.mjs} +6 -6
- package/dist/{update-CXKWHOIN.mjs → update-DcYwxb-R.mjs} +5 -5
- package/dist/{update-bFNKpay8.mjs → update-DmQJqGvX.mjs} +6 -6
- package/dist/{update-WLb9099y.mjs → update-DshCe-bl.mjs} +8 -8
- package/dist/{update-BfIGjAVA.mjs → update-Dt9-3Tqf.mjs} +5 -5
- package/dist/{update-DKQjMsuE.mjs → update-QNuvGuS3.mjs} +5 -5
- package/dist/{update-C1_aIL6X.mjs → update-bz-FKwuR.mjs} +7 -7
- package/dist/{update-QaBVvlz9.mjs → update-dJ2mlCXm.mjs} +6 -6
- package/dist/{update-dashcard-CKZC7scy.mjs → update-dashcard-CQP-qd6a.mjs} +5 -5
- package/dist/{upgrade-DkK1ZbEY.mjs → upgrade--_g3bacj.mjs} +4 -4
- package/dist/{upload-DOMoaR9f.mjs → upload-BOFc_eZD.mjs} +3 -3
- package/dist/{uuid-DDbLKnbe.mjs → uuid-DBKkosBq.mjs} +2 -2
- package/dist/{validate-DqQ7DQnP.mjs → validate-Bpqt3ntH.mjs} +1 -1
- package/dist/{validate-query-BYG7lyaY.mjs → validate-query-tECK019A.mjs} +2 -2
- package/dist/{values-Bc99Kkuv.mjs → values-BAhMnVh6.mjs} +3 -3
- package/dist/{verify-WvMbaT8W.mjs → verify-RLb2DoOX.mjs} +2 -2
- package/dist/{wait-DmgEPnHW.mjs → wait-BJ_87Yz0.mjs} +5 -5
- package/dist/{wait-flags-BpzyrpAL.mjs → wait-flags-DEn-3mjb.mjs} +2 -2
- package/package.json +1 -1
- package/dist/add-collection-CCpC-GUz.mjs +0 -9
- package/dist/create-BP8EIWrP.mjs +0 -161
- package/dist/destroy-B51EPvOJ.mjs +0 -168
- package/dist/export-Cy2DFtR2.mjs +0 -10
- package/dist/get-BdNC8WM8.mjs +0 -38
- package/dist/git-sync-CmB4YBmS.mjs +0 -31
- package/dist/is-dirty-DOy1TRcf.mjs +0 -8
- package/dist/list-lsPeumLP.mjs +0 -34
- package/dist/parse-id-xS1tWJAq.mjs +0 -18
- package/dist/profile-name-CZ1-Vtrh.mjs +0 -7
- package/dist/transform-CrOOqKen.mjs +0 -31
- package/dist/transform-job-J-E0Mec7.mjs +0 -25
- package/dist/workspace-BgH_4xFO.mjs +0 -75
- package/dist/workspace-LFtHxcw9.mjs +0 -19
- /package/dist/{measure-C3G1srWb.mjs → measure-ByY4U3ZK.mjs} +0 -0
- /package/dist/{resolve-BTZuO3Yq.mjs → resolve-8iT-dXgX.mjs} +0 -0
- /package/dist/{revision-message-flag-CHrJgFFx.mjs → revision-message-flag-vt_V8ACy.mjs} +0 -0
- /package/dist/{segment-CxRYEXeO.mjs → segment-eeGhLo7_.mjs} +0 -0
- /package/dist/{setting-m46MUtW5.mjs → setting-DADuiF5W.mjs} +0 -0
- /package/dist/{snippet-C8PVaPY9.mjs → snippet-CdjEhVCT.mjs} +0 -0
- /package/dist/{timeline-DEaiuB5V.mjs → timeline-2ShuGr4f.mjs} +0 -0
- /package/dist/{transform-DP6J2ber.mjs → transform-DNSMm1dV.mjs} +0 -0
- /package/dist/{transform-job-B5x8aYfV.mjs → transform-job-CvE0MBBo.mjs} +0 -0
- /package/dist/{transform-tag-BRwfWeHf.mjs → transform-tag-xdxjlDcD.mjs} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AbortError, BASELINE_CAPABILITIES, ConfigError, DEFAULT_MAX_BYTES, ENV_API_KEY, ENV_DISABLE_KEYRING, ENV_PROFILE, ENV_SKIP_PREFLIGHT, ENV_URL, MetabaseError, NetworkError, ResponseShapeError, TimeoutError, ValidationError, consumeLegacyEnvWarnings, errorMessage, flagConsumesValue, isNotFoundError, isPlainObject, normalizeFlag, package_default, readEnv, reportError, toAliasArray, warn } from "./capabilities-
|
|
1
|
+
import { AbortError, BASELINE_CAPABILITIES, ConfigError, DEFAULT_MAX_BYTES, ENV_API_KEY, ENV_DISABLE_KEYRING, ENV_PROFILE, ENV_SKIP_PREFLIGHT, ENV_URL, MetabaseError, NetworkError, ResponseShapeError, TimeoutError, ValidationError, consumeLegacyEnvWarnings, errorMessage, flagConsumesValue, isNotFoundError, isPlainObject, normalizeFlag, package_default, readEnv, reportError, toAliasArray, warn } from "./capabilities-CSjeWyLV.mjs";
|
|
2
2
|
import { setMetabaseAugment } from "./command-augment-D9pI9Vbh.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -6,8 +6,8 @@ import { promises } from "node:fs";
|
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { Entry } from "@napi-rs/keyring";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { setTimeout } from "node:timers/promises";
|
|
10
9
|
import { parse } from "semver";
|
|
10
|
+
import { setTimeout } from "node:timers/promises";
|
|
11
11
|
|
|
12
12
|
//#region src/runtime/json.ts
|
|
13
13
|
const JSON_CONTENT_TYPE = "application/json";
|
|
@@ -77,1177 +77,951 @@ const SessionProperties = z.object({
|
|
|
77
77
|
}).loose();
|
|
78
78
|
|
|
79
79
|
//#endregion
|
|
80
|
-
//#region src/core/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
function
|
|
87
|
-
const parsed =
|
|
88
|
-
|
|
89
|
-
return
|
|
80
|
+
//#region src/core/version/tag.ts
|
|
81
|
+
const ParsedVersionSchema = z.object({
|
|
82
|
+
tag: z.string(),
|
|
83
|
+
major: z.number().int().nonnegative(),
|
|
84
|
+
patch: z.number().int().nonnegative()
|
|
85
|
+
});
|
|
86
|
+
function tryParseTag(tag) {
|
|
87
|
+
const parsed = parse(tag);
|
|
88
|
+
if (parsed === null || parsed.major !== 0 && parsed.major !== 1) return null;
|
|
89
|
+
return {
|
|
90
|
+
tag,
|
|
91
|
+
major: parsed.minor,
|
|
92
|
+
patch: parsed.patch
|
|
93
|
+
};
|
|
90
94
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/core/auth/profile-record.ts
|
|
98
|
+
const ProbedUser = z.object({
|
|
99
|
+
id: z.number().int(),
|
|
100
|
+
name: z.string(),
|
|
101
|
+
isAdmin: z.boolean()
|
|
102
|
+
});
|
|
103
|
+
const ProfileLastProbe = z.object({
|
|
104
|
+
at: z.iso.datetime(),
|
|
105
|
+
version: ParsedVersionSchema.nullable(),
|
|
106
|
+
tokenFeatures: TokenFeatures.nullable(),
|
|
107
|
+
user: ProbedUser
|
|
108
|
+
});
|
|
109
|
+
const ProfileFailureKind = z.enum([
|
|
110
|
+
"auth",
|
|
111
|
+
"network",
|
|
112
|
+
"server"
|
|
96
113
|
]);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
const ProfileLastFailure = z.object({
|
|
115
|
+
at: z.iso.datetime(),
|
|
116
|
+
kind: ProfileFailureKind,
|
|
117
|
+
reason: z.string()
|
|
118
|
+
});
|
|
119
|
+
const ProfileOAuth = z.object({
|
|
120
|
+
accessToken: z.string().nullable(),
|
|
121
|
+
refreshToken: z.string().nullable(),
|
|
122
|
+
expiresAt: z.iso.datetime(),
|
|
123
|
+
clientId: z.string()
|
|
124
|
+
}).loose();
|
|
125
|
+
const ProfileAuthMethod = z.enum(["oauth", "apiKey"]);
|
|
126
|
+
const ProfileRecord = z.object({
|
|
127
|
+
name: z.string(),
|
|
128
|
+
url: z.string(),
|
|
129
|
+
apiKey: z.string().nullable(),
|
|
130
|
+
oauth: ProfileOAuth.nullable().default(null),
|
|
131
|
+
lastProbe: ProfileLastProbe.nullable(),
|
|
132
|
+
lastFailure: ProfileLastFailure.nullable()
|
|
133
|
+
}).loose();
|
|
134
|
+
const ProfilesFile = z.object({ profiles: z.array(ProfileRecord) }).loose();
|
|
135
|
+
function profileAuthMethod(record) {
|
|
136
|
+
return record.oauth !== null ? "oauth" : "apiKey";
|
|
110
137
|
}
|
|
111
138
|
|
|
112
139
|
//#endregion
|
|
113
|
-
//#region src/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
140
|
+
//#region src/core/auth/storage.ts
|
|
141
|
+
const KEYRING_SERVICE = "metabase-cli";
|
|
142
|
+
const PROFILES_FILE = "profiles.json";
|
|
143
|
+
const LEGACY_CREDENTIALS_FILE = "credentials.json";
|
|
144
|
+
const LEGACY_REJECTIONS_FILE = "rejections.json";
|
|
145
|
+
const PROFILES_FILE_MODE = 384;
|
|
146
|
+
const PROFILES_DIR_MODE = 448;
|
|
147
|
+
const DEFAULT_PROFILE = "default";
|
|
148
|
+
const LEGACY_STORAGE_NOTICE = "Old profile storage detected and ignored; re-run `mb auth login` for each profile.";
|
|
149
|
+
const KEYCHAIN_RESIDUAL_NOTICE = "warning: could not remove one or more secrets from the OS keychain; a stored token may remain — remove it manually or retry.";
|
|
150
|
+
const account = {
|
|
151
|
+
profileApiKey: (profile) => `profile:${profile}:apiKey`,
|
|
152
|
+
profileOAuthAccess: (profile) => `profile:${profile}:oauthAccess`,
|
|
153
|
+
profileOAuthRefresh: (profile) => `profile:${profile}:oauthRefresh`
|
|
154
|
+
};
|
|
155
|
+
let legacyWarningPending = false;
|
|
156
|
+
let keychainResidualPending = false;
|
|
157
|
+
let keyringDowngradeNotice = null;
|
|
158
|
+
function profilesFilePath() {
|
|
159
|
+
return join(configDir(), PROFILES_FILE);
|
|
130
160
|
}
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
combined: AbortSignal.any(signals),
|
|
137
|
-
processSignal
|
|
138
|
-
};
|
|
161
|
+
function consumeLegacyStorageWarning() {
|
|
162
|
+
if (!legacyWarningPending) return null;
|
|
163
|
+
legacyWarningPending = false;
|
|
164
|
+
return LEGACY_STORAGE_NOTICE;
|
|
139
165
|
}
|
|
140
|
-
function
|
|
141
|
-
|
|
166
|
+
function consumeKeychainResidualWarning() {
|
|
167
|
+
if (!keychainResidualPending) return null;
|
|
168
|
+
keychainResidualPending = false;
|
|
169
|
+
return KEYCHAIN_RESIDUAL_NOTICE;
|
|
142
170
|
}
|
|
143
|
-
function
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return new AbortError("aborted");
|
|
171
|
+
function consumeKeyringDowngradeWarning() {
|
|
172
|
+
const message = keyringDowngradeNotice;
|
|
173
|
+
keyringDowngradeNotice = null;
|
|
174
|
+
return message;
|
|
148
175
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//#region src/core/auth/credential.ts
|
|
152
|
-
const API_KEY_HEADER = "x-api-key";
|
|
153
|
-
const AUTHORIZATION_HEADER = "authorization";
|
|
154
|
-
const BEARER_PREFIX = "Bearer ";
|
|
155
|
-
const EXPIRY_SKEW_MS = 6e4;
|
|
156
|
-
const DEFAULT_EXPIRES_IN_SECONDS = 3600;
|
|
157
|
-
function credentialAuthHeader(credential) {
|
|
158
|
-
if (credential.kind === "apiKey") return {
|
|
159
|
-
name: API_KEY_HEADER,
|
|
160
|
-
value: credential.apiKey
|
|
161
|
-
};
|
|
162
|
-
return {
|
|
163
|
-
name: AUTHORIZATION_HEADER,
|
|
164
|
-
value: BEARER_PREFIX + credential.accessToken
|
|
165
|
-
};
|
|
176
|
+
function keyringEnabled() {
|
|
177
|
+
return readEnv(ENV_DISABLE_KEYRING) !== "1";
|
|
166
178
|
}
|
|
167
|
-
function
|
|
168
|
-
if (
|
|
169
|
-
|
|
179
|
+
function trySetKeyring(key, value) {
|
|
180
|
+
if (!keyringEnabled()) return false;
|
|
181
|
+
try {
|
|
182
|
+
new Entry(KEYRING_SERVICE, key).setPassword(value);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
170
187
|
}
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
188
|
+
function tryReadKeyring(key) {
|
|
189
|
+
if (!keyringEnabled()) return void 0;
|
|
190
|
+
try {
|
|
191
|
+
return new Entry(KEYRING_SERVICE, key).getPassword();
|
|
192
|
+
} catch {
|
|
193
|
+
return void 0;
|
|
194
|
+
}
|
|
175
195
|
}
|
|
176
|
-
function
|
|
177
|
-
|
|
196
|
+
function removeKeyringEntry(key) {
|
|
197
|
+
if (!keyringEnabled()) return "skipped";
|
|
198
|
+
try {
|
|
199
|
+
return new Entry(KEYRING_SERVICE, key).deletePassword() ? "removed" : "absent";
|
|
200
|
+
} catch {
|
|
201
|
+
return "failed";
|
|
202
|
+
}
|
|
178
203
|
}
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
204
|
+
async function readProfilesFile() {
|
|
205
|
+
const path = profilesFilePath();
|
|
206
|
+
let raw;
|
|
207
|
+
try {
|
|
208
|
+
raw = await promises.readFile(path, "utf8");
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (isNotFoundError(error)) {
|
|
211
|
+
await detectLegacyArtifacts();
|
|
212
|
+
return { profiles: [] };
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
const parsed = parseJsonResult(raw, ProfilesFile, { source: path });
|
|
217
|
+
if (parsed.ok) return parsed.value;
|
|
218
|
+
if (parsed.error instanceof ValidationError) {
|
|
219
|
+
legacyWarningPending = true;
|
|
220
|
+
return { profiles: [] };
|
|
221
|
+
}
|
|
222
|
+
throw parsed.error;
|
|
188
223
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
"x-api-key",
|
|
195
|
-
"x-metabase-session",
|
|
196
|
-
"cookie",
|
|
197
|
-
"set-cookie",
|
|
198
|
-
"proxy-authorization"
|
|
199
|
-
]);
|
|
200
|
-
const REDACTED = "[REDACTED]";
|
|
201
|
-
function redactHeaders(headers) {
|
|
202
|
-
const result = {};
|
|
203
|
-
const entries = headers instanceof Headers ? headers.entries() : Object.entries(headers);
|
|
204
|
-
for (const [key, value] of entries) result[key] = SECRET_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value;
|
|
205
|
-
return result;
|
|
224
|
+
async function detectLegacyArtifacts() {
|
|
225
|
+
const legacyCredentials = join(configDir(), LEGACY_CREDENTIALS_FILE);
|
|
226
|
+
const legacyRejections = join(configDir(), LEGACY_REJECTIONS_FILE);
|
|
227
|
+
const [credentialsExists, rejectionsExists] = await Promise.all([fileExists(legacyCredentials), fileExists(legacyRejections)]);
|
|
228
|
+
if (credentialsExists || rejectionsExists) legacyWarningPending = true;
|
|
206
229
|
}
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
230
|
+
async function fileExists(path) {
|
|
231
|
+
try {
|
|
232
|
+
await promises.access(path);
|
|
233
|
+
return true;
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
212
236
|
}
|
|
213
|
-
return result;
|
|
214
237
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
401: { retryable: false },
|
|
222
|
-
403: { retryable: false },
|
|
223
|
-
404: { retryable: false },
|
|
224
|
-
408: {
|
|
225
|
-
retryable: true,
|
|
226
|
-
message: "Metabase timed out responding."
|
|
227
|
-
},
|
|
228
|
-
425: { retryable: true },
|
|
229
|
-
429: {
|
|
230
|
-
retryable: true,
|
|
231
|
-
message: "Metabase rate-limited the request."
|
|
232
|
-
},
|
|
233
|
-
500: { retryable: true },
|
|
234
|
-
502: { retryable: true },
|
|
235
|
-
503: { retryable: true },
|
|
236
|
-
504: {
|
|
237
|
-
retryable: true,
|
|
238
|
-
message: "Metabase timed out responding."
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
|
-
const ErrorEnvelope = z.object({
|
|
242
|
-
message: z.string().optional(),
|
|
243
|
-
error: z.string().optional(),
|
|
244
|
-
"error-message": z.string().optional(),
|
|
245
|
-
via: z.array(z.object({ message: z.string().optional() }).loose()).optional(),
|
|
246
|
-
"specific-errors": z.unknown().optional(),
|
|
247
|
-
errors: z.unknown().optional()
|
|
248
|
-
}).loose();
|
|
249
|
-
const MAX_EXTRACTED_MESSAGE_LEN = 500;
|
|
250
|
-
const ELLIPSIS = "…";
|
|
251
|
-
var HttpError = class extends MetabaseError {
|
|
252
|
-
category = "http";
|
|
253
|
-
exitCode = 1;
|
|
254
|
-
status;
|
|
255
|
-
kind;
|
|
256
|
-
developerDetail;
|
|
257
|
-
constructor(input) {
|
|
258
|
-
const sanitizedBody = sanitizeBody(input.rawBody, input.redactionContext);
|
|
259
|
-
const redactedHeaders = redactHeaders(input.responseHeaders);
|
|
260
|
-
const kind = classifyKind(input.status, sanitizedBody, redactedHeaders);
|
|
261
|
-
super(input.overrideUserMessage ?? buildUserMessage(kind, input, sanitizedBody));
|
|
262
|
-
this.name = "HttpError";
|
|
263
|
-
this.status = input.status;
|
|
264
|
-
this.kind = kind;
|
|
265
|
-
this.developerDetail = {
|
|
266
|
-
status: input.status,
|
|
267
|
-
statusText: input.statusText,
|
|
268
|
-
method: input.method,
|
|
269
|
-
url: input.url,
|
|
270
|
-
responseHeaders: redactedHeaders,
|
|
271
|
-
body: sanitizedBody
|
|
272
|
-
};
|
|
238
|
+
async function writeProfilesFile(file) {
|
|
239
|
+
const path = profilesFilePath();
|
|
240
|
+
if (file.profiles.length === 0) {
|
|
241
|
+
await promises.unlink(path).catch(() => void 0);
|
|
242
|
+
await cleanupLegacyFiles();
|
|
243
|
+
return;
|
|
273
244
|
}
|
|
274
|
-
|
|
275
|
-
|
|
245
|
+
await promises.mkdir(dirname(path), {
|
|
246
|
+
recursive: true,
|
|
247
|
+
mode: PROFILES_DIR_MODE
|
|
248
|
+
});
|
|
249
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
250
|
+
await promises.writeFile(tmpPath, JSON.stringify(file, null, 2) + "\n", { mode: PROFILES_FILE_MODE });
|
|
251
|
+
if (process.platform !== "win32") await promises.chmod(tmpPath, PROFILES_FILE_MODE);
|
|
252
|
+
try {
|
|
253
|
+
await promises.rename(tmpPath, path);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
await promises.unlink(tmpPath).catch(() => void 0);
|
|
256
|
+
throw error;
|
|
276
257
|
}
|
|
277
|
-
|
|
278
|
-
function isRetryableStatus(status) {
|
|
279
|
-
return STATUS_CLASSIFICATIONS[status]?.retryable === true;
|
|
280
|
-
}
|
|
281
|
-
function sanitizeBody(rawBody, ctx) {
|
|
282
|
-
if (rawBody === null) return null;
|
|
283
|
-
if (ctx === void 0) return rawBody;
|
|
284
|
-
return redactBody(rawBody, ctx);
|
|
258
|
+
await cleanupLegacyFiles();
|
|
285
259
|
}
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
if (status === 404) return isRouteMissingResponse(sanitizedBody, redactedHeaders) ? "route-missing" : "resource-missing";
|
|
289
|
-
if (status === 429) return "rate-limit";
|
|
290
|
-
if (status >= 500 && status < 600) return "server-error";
|
|
291
|
-
return "generic";
|
|
260
|
+
async function cleanupLegacyFiles() {
|
|
261
|
+
await Promise.all([promises.unlink(join(configDir(), LEGACY_CREDENTIALS_FILE)).catch(() => void 0), promises.unlink(join(configDir(), LEGACY_REJECTIONS_FILE)).catch(() => void 0)]);
|
|
292
262
|
}
|
|
293
|
-
function
|
|
294
|
-
|
|
295
|
-
if (sanitizedBody?.includes(RESOURCE_MISSING_LITERAL)) return false;
|
|
296
|
-
if (redactedHeaders["content-type"]?.includes(JSON_CONTENT_TYPE)) return false;
|
|
297
|
-
if (sanitizedBody === null || sanitizedBody.trim() === "") return true;
|
|
298
|
-
return !parseJsonResult(sanitizedBody, ErrorEnvelope).ok;
|
|
263
|
+
function findRecord(file, name) {
|
|
264
|
+
return file.profiles.find((entry) => entry.name === name) ?? null;
|
|
299
265
|
}
|
|
300
|
-
function
|
|
301
|
-
|
|
302
|
-
if (kind === "resource-missing") return `Not found: ${input.method} ${pathFromUrl(input.url)}.`;
|
|
303
|
-
const fromBody = parseEnvelopeMessage(sanitizedBody);
|
|
304
|
-
if (fromBody !== null) return fromBody;
|
|
305
|
-
if (kind === "auth") return `Invalid or unauthorized API key (host: ${hostFromUrl(input.url)}).`;
|
|
306
|
-
return defaultMessageForStatus(input.status);
|
|
266
|
+
function sideIsKeyringBacked(record, side) {
|
|
267
|
+
return side === "oauth" ? record.oauth !== null && record.oauth.accessToken === null : record.oauth === null && record.apiKey === null;
|
|
307
268
|
}
|
|
308
|
-
function
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
return `This endpoint is not available on Metabase ${input.serverTag}: ${input.method} ${path}. The command may require a newer Metabase major version. Run 'mb auth list' to see this server's version.`;
|
|
269
|
+
function flagResidualIfUnconfirmed(existing, cleared, removals) {
|
|
270
|
+
if (existing === null || !removals.includes("failed")) return;
|
|
271
|
+
if (sideIsKeyringBacked(existing, cleared)) keychainResidualPending = true;
|
|
312
272
|
}
|
|
313
|
-
function
|
|
314
|
-
|
|
315
|
-
|
|
273
|
+
function fileLocation(key) {
|
|
274
|
+
return {
|
|
275
|
+
backend: "file",
|
|
276
|
+
path: profilesFilePath(),
|
|
277
|
+
account: key,
|
|
278
|
+
reason: keyringEnabled() ? "unavailable" : "disabled"
|
|
279
|
+
};
|
|
316
280
|
}
|
|
317
|
-
function
|
|
318
|
-
|
|
281
|
+
function keyringFallbackWarning(location) {
|
|
282
|
+
const cause = location.reason === "disabled" ? `OS keychain disabled via ${ENV_DISABLE_KEYRING}` : "OS keychain unavailable";
|
|
283
|
+
return `warning: ${cause}; credentials stored as plaintext at ${location.path}`;
|
|
319
284
|
}
|
|
320
|
-
function
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (viaMessage) return capLength(viaMessage);
|
|
329
|
-
const specific = formatErrorTree(envelope["specific-errors"]);
|
|
330
|
-
if (specific) return capLength(specific);
|
|
331
|
-
const generic = formatErrorTree(envelope.errors);
|
|
332
|
-
if (generic) return capLength(generic);
|
|
333
|
-
return null;
|
|
285
|
+
function persistSecret(key, value) {
|
|
286
|
+
if (trySetKeyring(key, value)) return {
|
|
287
|
+
backend: "keyring",
|
|
288
|
+
service: KEYRING_SERVICE,
|
|
289
|
+
account: key
|
|
290
|
+
};
|
|
291
|
+
removeKeyringEntry(key);
|
|
292
|
+
return fileLocation(key);
|
|
334
293
|
}
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return entries.map(formatLeafEntry).join("; ");
|
|
294
|
+
function resolveSecret(key, inline) {
|
|
295
|
+
if (inline !== null) return inline;
|
|
296
|
+
return tryReadKeyring(key) ?? null;
|
|
339
297
|
}
|
|
340
|
-
function
|
|
341
|
-
|
|
298
|
+
async function readProfileCredential(name = DEFAULT_PROFILE) {
|
|
299
|
+
const file = await readProfilesFile();
|
|
300
|
+
const record = findRecord(file, name);
|
|
301
|
+
if (record === null) return null;
|
|
302
|
+
return resolveRecordCredential(record);
|
|
342
303
|
}
|
|
343
|
-
function
|
|
344
|
-
if (
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
304
|
+
function resolveRecordCredential(record) {
|
|
305
|
+
if (record.oauth !== null) {
|
|
306
|
+
const accessToken = resolveSecret(account.profileOAuthAccess(record.name), record.oauth.accessToken);
|
|
307
|
+
const refreshToken = resolveSecret(account.profileOAuthRefresh(record.name), record.oauth.refreshToken);
|
|
308
|
+
if (accessToken === null || refreshToken === null) return null;
|
|
309
|
+
return {
|
|
310
|
+
url: record.url,
|
|
311
|
+
credential: {
|
|
312
|
+
kind: "oauth",
|
|
313
|
+
accessToken,
|
|
314
|
+
refreshToken,
|
|
315
|
+
expiresAt: record.oauth.expiresAt,
|
|
316
|
+
clientId: record.oauth.clientId
|
|
317
|
+
}
|
|
318
|
+
};
|
|
358
319
|
}
|
|
359
|
-
|
|
360
|
-
return
|
|
320
|
+
const apiKey = resolveSecret(account.profileApiKey(record.name), record.apiKey);
|
|
321
|
+
if (apiKey === null) return null;
|
|
322
|
+
return {
|
|
323
|
+
url: record.url,
|
|
324
|
+
credential: {
|
|
325
|
+
kind: "apiKey",
|
|
326
|
+
apiKey
|
|
327
|
+
}
|
|
328
|
+
};
|
|
361
329
|
}
|
|
362
|
-
function
|
|
363
|
-
|
|
364
|
-
return
|
|
330
|
+
async function readProfileRecord(name = DEFAULT_PROFILE) {
|
|
331
|
+
const file = await readProfilesFile();
|
|
332
|
+
return findRecord(file, name);
|
|
365
333
|
}
|
|
366
|
-
function
|
|
367
|
-
|
|
334
|
+
async function listProfileRecords() {
|
|
335
|
+
const file = await readProfilesFile();
|
|
336
|
+
return file.profiles;
|
|
368
337
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
EAI_AGAIN: (t) => `Could not resolve ${t.hostname} — check your network connection and the URL.`,
|
|
376
|
-
ECONNRESET: (t) => `Connection to ${t.host} was reset — the server may have closed it, or http/https may be mismatched.`,
|
|
377
|
-
ETIMEDOUT: (t) => `Connection to ${t.host} timed out — check the host, port, and your network.`
|
|
378
|
-
};
|
|
379
|
-
const TLS_ERROR_CODES = new Set([
|
|
380
|
-
"EPROTO",
|
|
381
|
-
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
382
|
-
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
383
|
-
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
384
|
-
"CERT_HAS_EXPIRED",
|
|
385
|
-
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
386
|
-
]);
|
|
387
|
-
function buildNetworkError(error, method, url) {
|
|
388
|
-
const fallback = errorMessage(error);
|
|
389
|
-
const code = causeCode(error);
|
|
390
|
-
const target = networkTarget(url);
|
|
391
|
-
return new NetworkError(networkMessage(code, target, fallback), {
|
|
392
|
-
method,
|
|
393
|
-
url,
|
|
394
|
-
cause: code ?? fallback
|
|
338
|
+
async function upsertRecord(file, name, updated) {
|
|
339
|
+
const exists = findRecord(file, name) !== null;
|
|
340
|
+
const profiles = exists ? file.profiles.map((entry) => entry.name === name ? updated : entry) : [...file.profiles, updated];
|
|
341
|
+
await writeProfilesFile({
|
|
342
|
+
...file,
|
|
343
|
+
profiles
|
|
395
344
|
});
|
|
396
345
|
}
|
|
397
|
-
function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
346
|
+
async function writeProfile(profile, name = DEFAULT_PROFILE) {
|
|
347
|
+
const location = persistSecret(account.profileApiKey(name), profile.apiKey);
|
|
348
|
+
const inlineApiKey = location.backend === "file" ? profile.apiKey : null;
|
|
349
|
+
const file = await readProfilesFile();
|
|
350
|
+
const existing = findRecord(file, name);
|
|
351
|
+
flagResidualIfUnconfirmed(existing, "oauth", [removeKeyringEntry(account.profileOAuthAccess(name)), removeKeyringEntry(account.profileOAuthRefresh(name))]);
|
|
352
|
+
const updated = existing === null ? {
|
|
353
|
+
name,
|
|
354
|
+
url: profile.url,
|
|
355
|
+
apiKey: inlineApiKey,
|
|
356
|
+
oauth: null,
|
|
357
|
+
lastProbe: null,
|
|
358
|
+
lastFailure: null
|
|
359
|
+
} : {
|
|
360
|
+
...existing,
|
|
361
|
+
url: profile.url,
|
|
362
|
+
apiKey: inlineApiKey,
|
|
363
|
+
oauth: null
|
|
364
|
+
};
|
|
365
|
+
await upsertRecord(file, name, updated);
|
|
366
|
+
return location;
|
|
403
367
|
}
|
|
404
|
-
function
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
368
|
+
async function writeOAuthProfile(url, credential, name = DEFAULT_PROFILE) {
|
|
369
|
+
const accessKey = account.profileOAuthAccess(name);
|
|
370
|
+
const refreshKey = account.profileOAuthRefresh(name);
|
|
371
|
+
const accessLocation = persistSecret(accessKey, credential.accessToken);
|
|
372
|
+
const refreshLocation = persistSecret(refreshKey, credential.refreshToken);
|
|
373
|
+
const onFile = accessLocation.backend === "file" || refreshLocation.backend === "file";
|
|
374
|
+
const oauth = {
|
|
375
|
+
accessToken: onFile ? credential.accessToken : null,
|
|
376
|
+
refreshToken: onFile ? credential.refreshToken : null,
|
|
377
|
+
expiresAt: credential.expiresAt,
|
|
378
|
+
clientId: credential.clientId
|
|
379
|
+
};
|
|
380
|
+
const file = await readProfilesFile();
|
|
381
|
+
const existing = findRecord(file, name);
|
|
382
|
+
if (onFile && existing !== null && sideIsKeyringBacked(existing, "oauth")) keyringDowngradeNotice = keyringFallbackWarning(fileLocation(accessKey));
|
|
383
|
+
flagResidualIfUnconfirmed(existing, "apiKey", [removeKeyringEntry(account.profileApiKey(name))]);
|
|
384
|
+
const updated = existing === null ? {
|
|
385
|
+
name,
|
|
386
|
+
url,
|
|
387
|
+
apiKey: null,
|
|
388
|
+
oauth,
|
|
389
|
+
lastProbe: null,
|
|
390
|
+
lastFailure: null
|
|
391
|
+
} : {
|
|
392
|
+
...existing,
|
|
393
|
+
url,
|
|
394
|
+
apiKey: null,
|
|
395
|
+
oauth
|
|
396
|
+
};
|
|
397
|
+
await upsertRecord(file, name, updated);
|
|
398
|
+
return onFile ? fileLocation(accessKey) : {
|
|
399
|
+
backend: "keyring",
|
|
400
|
+
service: KEYRING_SERVICE,
|
|
401
|
+
account: accessKey
|
|
402
|
+
};
|
|
408
403
|
}
|
|
409
|
-
function
|
|
410
|
-
const
|
|
404
|
+
async function writeProbeResult(name, input) {
|
|
405
|
+
const probe = ProfileLastProbe.parse({
|
|
406
|
+
at: new Date().toISOString(),
|
|
407
|
+
version: input.server.version,
|
|
408
|
+
tokenFeatures: input.server.tokenFeatures,
|
|
409
|
+
user: input.user
|
|
410
|
+
});
|
|
411
|
+
const file = await readProfilesFile();
|
|
412
|
+
const existing = findRecord(file, name);
|
|
413
|
+
if (existing === null) return null;
|
|
414
|
+
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
415
|
+
...entry,
|
|
416
|
+
lastProbe: probe,
|
|
417
|
+
lastFailure: null
|
|
418
|
+
} : entry);
|
|
419
|
+
await writeProfilesFile({
|
|
420
|
+
...file,
|
|
421
|
+
profiles
|
|
422
|
+
});
|
|
423
|
+
return probe;
|
|
424
|
+
}
|
|
425
|
+
async function writeProbeFailure(name, input) {
|
|
426
|
+
const failure$1 = ProfileLastFailure.parse({
|
|
427
|
+
at: new Date().toISOString(),
|
|
428
|
+
kind: input.kind,
|
|
429
|
+
reason: input.reason
|
|
430
|
+
});
|
|
431
|
+
const file = await readProfilesFile();
|
|
432
|
+
const existing = findRecord(file, name);
|
|
433
|
+
if (existing === null) return null;
|
|
434
|
+
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
435
|
+
...entry,
|
|
436
|
+
lastFailure: failure$1
|
|
437
|
+
} : entry);
|
|
438
|
+
await writeProfilesFile({
|
|
439
|
+
...file,
|
|
440
|
+
profiles
|
|
441
|
+
});
|
|
442
|
+
return failure$1;
|
|
443
|
+
}
|
|
444
|
+
async function clearProfile(name = DEFAULT_PROFILE) {
|
|
445
|
+
const file = await readProfilesFile();
|
|
446
|
+
const existing = findRecord(file, name);
|
|
447
|
+
const removals = [
|
|
448
|
+
removeKeyringEntry(account.profileApiKey(name)),
|
|
449
|
+
removeKeyringEntry(account.profileOAuthAccess(name)),
|
|
450
|
+
removeKeyringEntry(account.profileOAuthRefresh(name))
|
|
451
|
+
];
|
|
452
|
+
if (existing !== null) flagResidualIfUnconfirmed(existing, existing.oauth !== null ? "oauth" : "apiKey", removals);
|
|
453
|
+
if (existing === null) return false;
|
|
454
|
+
await writeProfilesFile({
|
|
455
|
+
...file,
|
|
456
|
+
profiles: file.profiles.filter((entry) => entry.name !== name)
|
|
457
|
+
});
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/runtime/signal.ts
|
|
463
|
+
function createProcessAbortHandler() {
|
|
464
|
+
const controller = new AbortController();
|
|
465
|
+
const handler = () => {
|
|
466
|
+
if (!controller.signal.aborted) controller.abort(new AbortError("interrupted"));
|
|
467
|
+
process.off("SIGINT", handler);
|
|
468
|
+
};
|
|
469
|
+
process.on("SIGINT", handler);
|
|
411
470
|
return {
|
|
412
|
-
|
|
413
|
-
|
|
471
|
+
signal: controller.signal,
|
|
472
|
+
uninstall: () => process.off("SIGINT", handler)
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
let singleton = null;
|
|
476
|
+
function getProcessAbortSignal() {
|
|
477
|
+
if (singleton === null) singleton = createProcessAbortHandler().signal;
|
|
478
|
+
return singleton;
|
|
479
|
+
}
|
|
480
|
+
function combineAborts(timeoutSignal, callerSignal) {
|
|
481
|
+
const processSignal = getProcessAbortSignal();
|
|
482
|
+
const signals = [timeoutSignal, processSignal];
|
|
483
|
+
if (callerSignal) signals.push(callerSignal);
|
|
484
|
+
return {
|
|
485
|
+
combined: AbortSignal.any(signals),
|
|
486
|
+
processSignal
|
|
414
487
|
};
|
|
415
488
|
}
|
|
489
|
+
function throwIfAborted(...signals) {
|
|
490
|
+
for (const signal of signals) if (signal?.aborted) throw abortReason(signal);
|
|
491
|
+
}
|
|
492
|
+
function abortReason(signal) {
|
|
493
|
+
const reason = signal.reason;
|
|
494
|
+
if (reason instanceof MetabaseError) return reason;
|
|
495
|
+
if (reason instanceof Error || typeof reason === "string") return new AbortError(errorMessage(reason) || "aborted");
|
|
496
|
+
return new AbortError("aborted");
|
|
497
|
+
}
|
|
416
498
|
|
|
417
499
|
//#endregion
|
|
418
|
-
//#region src/core/
|
|
419
|
-
const
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (
|
|
426
|
-
|
|
500
|
+
//#region src/core/auth/credential.ts
|
|
501
|
+
const API_KEY_HEADER = "x-api-key";
|
|
502
|
+
const AUTHORIZATION_HEADER = "authorization";
|
|
503
|
+
const BEARER_PREFIX = "Bearer ";
|
|
504
|
+
const EXPIRY_SKEW_MS = 6e4;
|
|
505
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 3600;
|
|
506
|
+
function credentialAuthHeader(credential) {
|
|
507
|
+
if (credential.kind === "apiKey") return {
|
|
508
|
+
name: API_KEY_HEADER,
|
|
509
|
+
value: credential.apiKey
|
|
510
|
+
};
|
|
511
|
+
return {
|
|
512
|
+
name: AUTHORIZATION_HEADER,
|
|
513
|
+
value: BEARER_PREFIX + credential.accessToken
|
|
514
|
+
};
|
|
427
515
|
}
|
|
428
|
-
function
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
if (Number.isFinite(numeric) && numeric >= 0) return Math.round(numeric * MS_PER_SECOND);
|
|
432
|
-
const dateMs = Date.parse(header);
|
|
433
|
-
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
434
|
-
return null;
|
|
516
|
+
function credentialSecrets(credential) {
|
|
517
|
+
if (credential.kind === "apiKey") return [credential.apiKey];
|
|
518
|
+
return [credential.accessToken, credential.refreshToken];
|
|
435
519
|
}
|
|
436
|
-
function
|
|
437
|
-
|
|
520
|
+
function isOAuthExpired(credential, nowMs) {
|
|
521
|
+
const expiryMs = Date.parse(credential.expiresAt);
|
|
522
|
+
if (Number.isNaN(expiryMs)) return true;
|
|
523
|
+
return expiryMs - EXPIRY_SKEW_MS <= nowMs;
|
|
438
524
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
525
|
+
function expiresAtFromNow(expiresInSeconds, nowMs) {
|
|
526
|
+
return new Date(nowMs + expiresInSeconds * 1e3).toISOString();
|
|
527
|
+
}
|
|
528
|
+
function oauthCredentialFromTokens(tokens, refreshToken, clientId, nowMs) {
|
|
529
|
+
return {
|
|
530
|
+
kind: "oauth",
|
|
531
|
+
accessToken: tokens.access_token,
|
|
532
|
+
refreshToken,
|
|
533
|
+
expiresAt: expiresAtFromNow(tokens.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS, nowMs),
|
|
534
|
+
clientId
|
|
535
|
+
};
|
|
447
536
|
}
|
|
448
537
|
|
|
449
538
|
//#endregion
|
|
450
|
-
//#region src/core/http/
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
"GET",
|
|
459
|
-
"HEAD",
|
|
460
|
-
"OPTIONS"
|
|
539
|
+
//#region src/core/http/sanitize.ts
|
|
540
|
+
const SECRET_HEADER_NAMES = new Set([
|
|
541
|
+
"authorization",
|
|
542
|
+
"x-api-key",
|
|
543
|
+
"x-metabase-session",
|
|
544
|
+
"cookie",
|
|
545
|
+
"set-cookie",
|
|
546
|
+
"proxy-authorization"
|
|
461
547
|
]);
|
|
462
|
-
const
|
|
463
|
-
function
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
let
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
refreshInFlight ??= refreshOnce(refresh).finally(() => {
|
|
475
|
-
refreshInFlight = null;
|
|
476
|
-
});
|
|
477
|
-
return refreshInFlight;
|
|
478
|
-
}
|
|
479
|
-
async function refreshOnce(refresh) {
|
|
480
|
-
const next = await refresh();
|
|
481
|
-
if (next === null) return false;
|
|
482
|
-
credential = next;
|
|
483
|
-
for (const secret of credentialSecrets(next)) knownSecrets.add(secret);
|
|
484
|
-
return true;
|
|
485
|
-
}
|
|
486
|
-
async function attemptOnce(prepared, attempt) {
|
|
487
|
-
const hasRetriesLeft = attempt < prepared.retries;
|
|
488
|
-
const timeoutSignal = AbortSignal.timeout(prepared.timeoutMs);
|
|
489
|
-
const { combined, processSignal } = combineAborts(timeoutSignal, prepared.callerSignal);
|
|
490
|
-
let response;
|
|
491
|
-
try {
|
|
492
|
-
response = await fetchImpl(prepared.url, {
|
|
493
|
-
method: prepared.method,
|
|
494
|
-
headers: prepared.headers,
|
|
495
|
-
body: prepared.body,
|
|
496
|
-
signal: combined
|
|
497
|
-
});
|
|
498
|
-
} catch (error) {
|
|
499
|
-
throwIfAborted(prepared.callerSignal, processSignal);
|
|
500
|
-
if (hasRetriesLeft) return {
|
|
501
|
-
kind: "retry",
|
|
502
|
-
delayMs: backoffDelay({ attempt })
|
|
503
|
-
};
|
|
504
|
-
if (timeoutSignal.aborted) throw new TimeoutError(`Request timed out after ${prepared.timeoutMs}ms`, {
|
|
505
|
-
kind: "http",
|
|
506
|
-
method: prepared.method,
|
|
507
|
-
url: prepared.url,
|
|
508
|
-
timeoutMs: prepared.timeoutMs
|
|
509
|
-
});
|
|
510
|
-
throw buildNetworkError(error, prepared.method, prepared.url);
|
|
511
|
-
}
|
|
512
|
-
const canRetryStatus = hasRetriesLeft && prepared.idempotent;
|
|
513
|
-
if (!response.ok && isRetryableStatus(response.status) && canRetryStatus) {
|
|
514
|
-
const retryAfter = response.headers.get("Retry-After");
|
|
515
|
-
response.body?.cancel().catch(() => void 0);
|
|
516
|
-
return {
|
|
517
|
-
kind: "retry",
|
|
518
|
-
delayMs: backoffDelay({
|
|
519
|
-
attempt,
|
|
520
|
-
retryAfterHeader: retryAfter
|
|
521
|
-
})
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
if (!response.ok) {
|
|
525
|
-
const rawBody = await readBodyForError(response);
|
|
526
|
-
const serverTag = await getServerTag();
|
|
527
|
-
throw new HttpError({
|
|
528
|
-
status: response.status,
|
|
529
|
-
statusText: response.statusText,
|
|
530
|
-
method: prepared.method,
|
|
531
|
-
url: prepared.url,
|
|
532
|
-
responseHeaders: response.headers,
|
|
533
|
-
rawBody,
|
|
534
|
-
serverTag,
|
|
535
|
-
redactionContext
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
assertContentType(response, prepared);
|
|
539
|
-
return {
|
|
540
|
-
kind: "success",
|
|
541
|
-
response
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
async function executeRaw(prepared) {
|
|
545
|
-
return runWithRetries((attempt) => attemptOnce(prepared, attempt), prepared.callerSignal);
|
|
548
|
+
const REDACTED = "[REDACTED]";
|
|
549
|
+
function redactHeaders(headers) {
|
|
550
|
+
const result = {};
|
|
551
|
+
const entries = headers instanceof Headers ? headers.entries() : Object.entries(headers);
|
|
552
|
+
for (const [key, value] of entries) result[key] = SECRET_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value;
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
function redactBody(body, ctx) {
|
|
556
|
+
let result = body;
|
|
557
|
+
for (const secret of ctx.knownSecrets) {
|
|
558
|
+
if (secret.length === 0) continue;
|
|
559
|
+
result = result.replaceAll(secret, REDACTED);
|
|
546
560
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/core/http/errors.ts
|
|
566
|
+
const ROUTE_MISSING_LITERAL = "API endpoint does not exist.";
|
|
567
|
+
const RESOURCE_MISSING_LITERAL = "Not found.";
|
|
568
|
+
const STATUS_CLASSIFICATIONS = {
|
|
569
|
+
401: { retryable: false },
|
|
570
|
+
403: { retryable: false },
|
|
571
|
+
404: { retryable: false },
|
|
572
|
+
408: {
|
|
573
|
+
retryable: true,
|
|
574
|
+
message: "Metabase timed out responding."
|
|
575
|
+
},
|
|
576
|
+
425: { retryable: true },
|
|
577
|
+
429: {
|
|
578
|
+
retryable: true,
|
|
579
|
+
message: "Metabase rate-limited the request."
|
|
580
|
+
},
|
|
581
|
+
500: { retryable: true },
|
|
582
|
+
502: { retryable: true },
|
|
583
|
+
503: { retryable: true },
|
|
584
|
+
504: {
|
|
585
|
+
retryable: true,
|
|
586
|
+
message: "Metabase timed out responding."
|
|
566
587
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
588
|
+
};
|
|
589
|
+
const ErrorEnvelope = z.object({
|
|
590
|
+
message: z.string().optional(),
|
|
591
|
+
error: z.string().optional(),
|
|
592
|
+
"error-message": z.string().optional(),
|
|
593
|
+
via: z.array(z.object({ message: z.string().optional() }).loose()).optional(),
|
|
594
|
+
"specific-errors": z.unknown().optional(),
|
|
595
|
+
errors: z.unknown().optional()
|
|
596
|
+
}).loose();
|
|
597
|
+
const MAX_EXTRACTED_MESSAGE_LEN = 500;
|
|
598
|
+
const ELLIPSIS = "…";
|
|
599
|
+
var HttpError = class extends MetabaseError {
|
|
600
|
+
category = "http";
|
|
601
|
+
exitCode = 1;
|
|
602
|
+
status;
|
|
603
|
+
kind;
|
|
604
|
+
developerDetail;
|
|
605
|
+
constructor(input) {
|
|
606
|
+
const sanitizedBody = sanitizeBody(input.rawBody, input.redactionContext);
|
|
607
|
+
const redactedHeaders = redactHeaders(input.responseHeaders);
|
|
608
|
+
const kind = classifyKind(input.status, sanitizedBody, redactedHeaders);
|
|
609
|
+
super(input.overrideUserMessage ?? buildUserMessage(kind, input, sanitizedBody));
|
|
610
|
+
this.name = "HttpError";
|
|
611
|
+
this.status = input.status;
|
|
612
|
+
this.kind = kind;
|
|
613
|
+
this.developerDetail = {
|
|
614
|
+
status: input.status,
|
|
615
|
+
statusText: input.statusText,
|
|
616
|
+
method: input.method,
|
|
617
|
+
url: input.url,
|
|
618
|
+
responseHeaders: redactedHeaders,
|
|
619
|
+
body: sanitizedBody
|
|
596
620
|
};
|
|
597
621
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
return (await executeWithAuthRefresh(path, opts ?? {})).response;
|
|
601
|
-
},
|
|
602
|
-
async requestParsed(schema, path, opts) {
|
|
603
|
-
const { response, prepared } = await executeWithAuthRefresh(path, {
|
|
604
|
-
...opts,
|
|
605
|
-
expectContentType: "json"
|
|
606
|
-
});
|
|
607
|
-
const text = await response.text();
|
|
608
|
-
try {
|
|
609
|
-
return parseJson(text, schema, { source: prepared.url });
|
|
610
|
-
} catch (error) {
|
|
611
|
-
if (error instanceof ValidationError) throw new ResponseShapeError({
|
|
612
|
-
method: prepared.method,
|
|
613
|
-
url: prepared.url,
|
|
614
|
-
status: response.status,
|
|
615
|
-
zodIssues: error.developerDetail.zodIssues,
|
|
616
|
-
serverTag: await getServerTag()
|
|
617
|
-
});
|
|
618
|
-
throw error;
|
|
619
|
-
}
|
|
620
|
-
},
|
|
621
|
-
async requestStream(path, opts) {
|
|
622
|
-
const { response, prepared } = await executeWithAuthRefresh(path, {
|
|
623
|
-
...opts,
|
|
624
|
-
expectContentType: opts?.expectContentType ?? "binary"
|
|
625
|
-
});
|
|
626
|
-
if (!response.body) throw new NetworkError("Response had no body to stream", {
|
|
627
|
-
method: prepared.method,
|
|
628
|
-
url: prepared.url,
|
|
629
|
-
cause: "missing body"
|
|
630
|
-
});
|
|
631
|
-
return response.body;
|
|
632
|
-
}
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
function buildUrl(baseUrl, path, query) {
|
|
636
|
-
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
637
|
-
const target = new URL(baseUrl + normalizedPath);
|
|
638
|
-
if (query) for (const [key, value] of Object.entries(query)) {
|
|
639
|
-
if (value === void 0) continue;
|
|
640
|
-
if (Array.isArray(value)) for (const entry of value) target.searchParams.append(key, String(entry));
|
|
641
|
-
else target.searchParams.append(key, String(value));
|
|
622
|
+
get isRetryable() {
|
|
623
|
+
return isRetryableStatus(this.status);
|
|
642
624
|
}
|
|
643
|
-
|
|
625
|
+
};
|
|
626
|
+
function isRetryableStatus(status) {
|
|
627
|
+
return STATUS_CLASSIFICATIONS[status]?.retryable === true;
|
|
644
628
|
}
|
|
645
|
-
function
|
|
646
|
-
if (
|
|
647
|
-
if (
|
|
648
|
-
return
|
|
629
|
+
function sanitizeBody(rawBody, ctx) {
|
|
630
|
+
if (rawBody === null) return null;
|
|
631
|
+
if (ctx === void 0) return rawBody;
|
|
632
|
+
return redactBody(rawBody, ctx);
|
|
649
633
|
}
|
|
650
|
-
function
|
|
651
|
-
if (
|
|
652
|
-
|
|
653
|
-
if (
|
|
654
|
-
if (
|
|
655
|
-
|
|
634
|
+
function classifyKind(status, sanitizedBody, redactedHeaders) {
|
|
635
|
+
if (status === 401 || status === 403) return "auth";
|
|
636
|
+
if (status === 404) return isRouteMissingResponse(sanitizedBody, redactedHeaders) ? "route-missing" : "resource-missing";
|
|
637
|
+
if (status === 429) return "rate-limit";
|
|
638
|
+
if (status >= 500 && status < 600) return "server-error";
|
|
639
|
+
return "generic";
|
|
656
640
|
}
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
url: prepared.url,
|
|
664
|
-
responseHeaders: response.headers,
|
|
665
|
-
rawBody: null,
|
|
666
|
-
overrideUserMessage: `Expected ${expected} response but got ${actual}`
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
async function readBodyForError(response) {
|
|
670
|
-
try {
|
|
671
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
672
|
-
return buffer.subarray(0, ERROR_BODY_BYTE_CAP).toString("utf8");
|
|
673
|
-
} catch (error) {
|
|
674
|
-
return `[body read failed: ${errorMessage(error)}]`;
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
//#endregion
|
|
679
|
-
//#region src/core/http/oauth.ts
|
|
680
|
-
const DISCOVERY_PATH = "/.well-known/oauth-authorization-server";
|
|
681
|
-
const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
|
|
682
|
-
const OAUTH_TIMEOUT_MS = 3e4;
|
|
683
|
-
async function oauthFetch(request) {
|
|
684
|
-
const timeoutSignal = AbortSignal.timeout(OAUTH_TIMEOUT_MS);
|
|
685
|
-
try {
|
|
686
|
-
return await fetch(request.url, {
|
|
687
|
-
method: request.method,
|
|
688
|
-
headers: {
|
|
689
|
-
...request.headers,
|
|
690
|
-
"user-agent": USER_AGENT
|
|
691
|
-
},
|
|
692
|
-
...request.body !== void 0 ? { body: request.body } : {},
|
|
693
|
-
signal: timeoutSignal
|
|
694
|
-
});
|
|
695
|
-
} catch (error) {
|
|
696
|
-
if (timeoutSignal.aborted) throw new TimeoutError(`OAuth request timed out after ${OAUTH_TIMEOUT_MS}ms`, {
|
|
697
|
-
kind: "http",
|
|
698
|
-
method: request.method,
|
|
699
|
-
url: request.url,
|
|
700
|
-
timeoutMs: OAUTH_TIMEOUT_MS
|
|
701
|
-
});
|
|
702
|
-
throw buildNetworkError(error, request.method, request.url);
|
|
703
|
-
}
|
|
641
|
+
function isRouteMissingResponse(sanitizedBody, redactedHeaders) {
|
|
642
|
+
if (sanitizedBody?.includes(ROUTE_MISSING_LITERAL)) return true;
|
|
643
|
+
if (sanitizedBody?.includes(RESOURCE_MISSING_LITERAL)) return false;
|
|
644
|
+
if (redactedHeaders["content-type"]?.includes(JSON_CONTENT_TYPE)) return false;
|
|
645
|
+
if (sanitizedBody === null || sanitizedBody.trim() === "") return true;
|
|
646
|
+
return !parseJsonResult(sanitizedBody, ErrorEnvelope).ok;
|
|
704
647
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
revocation_endpoint: z.string().optional(),
|
|
713
|
-
scopes_supported: z.array(z.string()).optional()
|
|
714
|
-
}).loose();
|
|
715
|
-
const RegisteredClient = z.object({ client_id: z.string() }).loose();
|
|
716
|
-
const OAuthTokens = z.object({
|
|
717
|
-
access_token: z.string(),
|
|
718
|
-
token_type: z.string(),
|
|
719
|
-
expires_in: z.number().int().optional(),
|
|
720
|
-
refresh_token: z.string().optional()
|
|
721
|
-
}).loose();
|
|
722
|
-
const OAuthErrorBody = z.object({
|
|
723
|
-
error: z.string(),
|
|
724
|
-
error_description: z.string().optional()
|
|
725
|
-
}).loose();
|
|
726
|
-
async function readJson(response, schema, source) {
|
|
727
|
-
let raw;
|
|
728
|
-
try {
|
|
729
|
-
raw = await response.json();
|
|
730
|
-
} catch (error) {
|
|
731
|
-
throw new ConfigError(`${source}: invalid JSON response (${errorMessage(error)})`);
|
|
732
|
-
}
|
|
733
|
-
const parsed = schema.safeParse(raw);
|
|
734
|
-
if (!parsed.success) throw new ConfigError(`${source}: unexpected response shape`);
|
|
735
|
-
return parsed.data;
|
|
648
|
+
function buildUserMessage(kind, input, sanitizedBody) {
|
|
649
|
+
if (kind === "route-missing") return buildRouteMissingMessage(input);
|
|
650
|
+
if (kind === "resource-missing") return `Not found: ${input.method} ${pathFromUrl(input.url)}.`;
|
|
651
|
+
const fromBody = parseEnvelopeMessage(sanitizedBody);
|
|
652
|
+
if (fromBody !== null) return fromBody;
|
|
653
|
+
if (kind === "auth") return `Invalid or unauthorized API key (host: ${hostFromUrl(input.url)}).`;
|
|
654
|
+
return defaultMessageForStatus(input.status);
|
|
736
655
|
}
|
|
737
|
-
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
|
|
741
|
-
statusText: response.statusText,
|
|
742
|
-
method,
|
|
743
|
-
url,
|
|
744
|
-
responseHeaders: response.headers,
|
|
745
|
-
rawBody
|
|
746
|
-
});
|
|
747
|
-
const parsed = parseJsonResult(rawBody, OAuthErrorBody);
|
|
748
|
-
if (parsed.ok) {
|
|
749
|
-
const detail = parsed.value.error_description ?? parsed.value.error;
|
|
750
|
-
return new ConfigError(`${source} failed (${response.status}): ${detail}`);
|
|
751
|
-
}
|
|
752
|
-
return new ConfigError(`${source} failed (${response.status})`);
|
|
656
|
+
function buildRouteMissingMessage(input) {
|
|
657
|
+
const path = pathFromUrl(input.url);
|
|
658
|
+
if (!input.serverTag) return `This endpoint is not available on the connected Metabase: ${input.method} ${path}.`;
|
|
659
|
+
return `This endpoint is not available on Metabase ${input.serverTag}: ${input.method} ${path}. The command may require a newer Metabase major version. Run 'mb auth list' to see this server's version.`;
|
|
753
660
|
}
|
|
754
|
-
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
method: "POST",
|
|
758
|
-
headers: {
|
|
759
|
-
"content-type": FORM_CONTENT_TYPE,
|
|
760
|
-
accept: JSON_CONTENT_TYPE
|
|
761
|
-
},
|
|
762
|
-
body: new URLSearchParams(params)
|
|
763
|
-
});
|
|
764
|
-
if (!response.ok) throw await failure(response, source, "POST", url);
|
|
765
|
-
return readJson(response, schema, source);
|
|
661
|
+
function pathFromUrl(url) {
|
|
662
|
+
const parsed = new URL(url);
|
|
663
|
+
return parsed.pathname + parsed.search;
|
|
766
664
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const url = `${baseUrl}${DISCOVERY_PATH}`;
|
|
770
|
-
const response = await oauthFetch({
|
|
771
|
-
url,
|
|
772
|
-
method: "GET",
|
|
773
|
-
headers: { accept: JSON_CONTENT_TYPE }
|
|
774
|
-
});
|
|
775
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
776
|
-
if (!response.ok || !contentType.includes("json")) {
|
|
777
|
-
await response.body?.cancel().catch(() => void 0);
|
|
778
|
-
return null;
|
|
779
|
-
}
|
|
780
|
-
const metadata = await readJson(response, OAuthServerMetadata, "OAuth discovery");
|
|
781
|
-
const scopes = metadata.scopes_supported;
|
|
782
|
-
if (scopes !== void 0 && !scopes.includes(requiredScope)) return null;
|
|
783
|
-
assertEndpointOrigin(metadata.issuer, baseUrl, "issuer");
|
|
784
|
-
assertEndpointOrigin(metadata.authorization_endpoint, baseUrl, "authorization endpoint");
|
|
785
|
-
assertEndpointOrigin(metadata.token_endpoint, baseUrl, "token endpoint");
|
|
786
|
-
if (metadata.registration_endpoint !== void 0) assertEndpointOrigin(metadata.registration_endpoint, baseUrl, "registration endpoint");
|
|
787
|
-
if (metadata.revocation_endpoint !== void 0) assertEndpointOrigin(metadata.revocation_endpoint, baseUrl, "revocation endpoint");
|
|
788
|
-
return metadata;
|
|
665
|
+
function hostFromUrl(url) {
|
|
666
|
+
return new URL(url).host;
|
|
789
667
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return
|
|
668
|
+
function parseEnvelopeMessage(sanitizedBody) {
|
|
669
|
+
if (!sanitizedBody) return null;
|
|
670
|
+
const result = parseJsonResult(sanitizedBody, ErrorEnvelope);
|
|
671
|
+
if (!result.ok) return null;
|
|
672
|
+
const envelope = result.value;
|
|
673
|
+
const topLevel = envelope.message ?? envelope.error ?? envelope["error-message"];
|
|
674
|
+
if (topLevel) return capLength(topLevel);
|
|
675
|
+
const viaMessage = envelope.via?.find((entry) => entry.message)?.message;
|
|
676
|
+
if (viaMessage) return capLength(viaMessage);
|
|
677
|
+
const specific = formatErrorTree(envelope["specific-errors"]);
|
|
678
|
+
if (specific) return capLength(specific);
|
|
679
|
+
const generic = formatErrorTree(envelope.errors);
|
|
680
|
+
if (generic) return capLength(generic);
|
|
681
|
+
return null;
|
|
794
682
|
}
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
headers: {
|
|
800
|
-
"content-type": JSON_CONTENT_TYPE,
|
|
801
|
-
accept: JSON_CONTENT_TYPE
|
|
802
|
-
},
|
|
803
|
-
body: JSON.stringify({
|
|
804
|
-
application_type: "native",
|
|
805
|
-
client_name: input.clientName,
|
|
806
|
-
redirect_uris: [input.redirectUri],
|
|
807
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
808
|
-
response_types: ["code"],
|
|
809
|
-
token_endpoint_auth_method: "none",
|
|
810
|
-
scope: input.scope
|
|
811
|
-
})
|
|
812
|
-
});
|
|
813
|
-
if (!response.ok) throw await failure(response, "OAuth client registration", "POST", input.registrationEndpoint);
|
|
814
|
-
return readJson(response, RegisteredClient, "OAuth client registration");
|
|
683
|
+
function formatErrorTree(value) {
|
|
684
|
+
const entries = collectLeafEntries(value, []);
|
|
685
|
+
if (entries.length === 0) return null;
|
|
686
|
+
return entries.map(formatLeafEntry).join("; ");
|
|
815
687
|
}
|
|
816
|
-
function
|
|
817
|
-
return
|
|
818
|
-
grant_type: "authorization_code",
|
|
819
|
-
code: input.code,
|
|
820
|
-
redirect_uri: input.redirectUri,
|
|
821
|
-
client_id: input.clientId,
|
|
822
|
-
code_verifier: input.codeVerifier
|
|
823
|
-
}, OAuthTokens, "OAuth token exchange");
|
|
688
|
+
function formatLeafEntry(entry) {
|
|
689
|
+
return entry.path === "" ? entry.message : `${entry.path}: ${entry.message}`;
|
|
824
690
|
}
|
|
825
|
-
function
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
691
|
+
function collectLeafEntries(value, path) {
|
|
692
|
+
if (typeof value === "string") {
|
|
693
|
+
const trimmed = value.trim();
|
|
694
|
+
return trimmed === "" ? [] : [{
|
|
695
|
+
path: path.join("."),
|
|
696
|
+
message: trimmed
|
|
697
|
+
}];
|
|
698
|
+
}
|
|
699
|
+
if (Array.isArray(value)) {
|
|
700
|
+
const messages = value.filter((entry) => typeof entry === "string" && entry.trim() !== "");
|
|
701
|
+
if (messages.length === 0) return [];
|
|
702
|
+
return [{
|
|
703
|
+
path: path.join("."),
|
|
704
|
+
message: messages.join("; ")
|
|
705
|
+
}];
|
|
706
|
+
}
|
|
707
|
+
if (isPlainObject(value)) return Object.entries(value).flatMap(([key, child]) => collectLeafEntries(child, [...path, key]));
|
|
708
|
+
return [];
|
|
831
709
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
method: "POST",
|
|
836
|
-
headers: { "content-type": FORM_CONTENT_TYPE },
|
|
837
|
-
body: new URLSearchParams({
|
|
838
|
-
token: input.token,
|
|
839
|
-
client_id: input.clientId
|
|
840
|
-
})
|
|
841
|
-
});
|
|
842
|
-
if (!response.ok) throw await failure(response, "OAuth token revocation", "POST", input.revocationEndpoint);
|
|
843
|
-
await response.body?.cancel().catch(() => void 0);
|
|
710
|
+
function capLength(message) {
|
|
711
|
+
if (message.length <= MAX_EXTRACTED_MESSAGE_LEN) return message;
|
|
712
|
+
return message.slice(0, MAX_EXTRACTED_MESSAGE_LEN - ELLIPSIS.length) + ELLIPSIS;
|
|
844
713
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
//#region src/core/version/tag.ts
|
|
848
|
-
const ParsedVersionSchema = z.object({
|
|
849
|
-
tag: z.string(),
|
|
850
|
-
major: z.number().int().nonnegative(),
|
|
851
|
-
patch: z.number().int().nonnegative()
|
|
852
|
-
});
|
|
853
|
-
function tryParseTag(tag) {
|
|
854
|
-
const parsed = parse(tag);
|
|
855
|
-
if (parsed === null || parsed.major !== 0 && parsed.major !== 1) return null;
|
|
856
|
-
return {
|
|
857
|
-
tag,
|
|
858
|
-
major: parsed.minor,
|
|
859
|
-
patch: parsed.patch
|
|
860
|
-
};
|
|
714
|
+
function defaultMessageForStatus(status) {
|
|
715
|
+
return STATUS_CLASSIFICATIONS[status]?.message ?? `Metabase returned ${status}.`;
|
|
861
716
|
}
|
|
862
717
|
|
|
863
718
|
//#endregion
|
|
864
|
-
//#region src/core/
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
"
|
|
878
|
-
"
|
|
879
|
-
"server"
|
|
719
|
+
//#region src/core/http/network-error.ts
|
|
720
|
+
const NETWORK_HINTS = {
|
|
721
|
+
ECONNREFUSED: (t) => `Connection refused by ${t.host} — is Metabase running and is the port correct?`,
|
|
722
|
+
ENOTFOUND: (t) => `Host not found: ${t.hostname} — check the URL.`,
|
|
723
|
+
EAI_AGAIN: (t) => `Could not resolve ${t.hostname} — check your network connection and the URL.`,
|
|
724
|
+
ECONNRESET: (t) => `Connection to ${t.host} was reset — the server may have closed it, or http/https may be mismatched.`,
|
|
725
|
+
ETIMEDOUT: (t) => `Connection to ${t.host} timed out — check the host, port, and your network.`
|
|
726
|
+
};
|
|
727
|
+
const TLS_ERROR_CODES = new Set([
|
|
728
|
+
"EPROTO",
|
|
729
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
730
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
731
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
732
|
+
"CERT_HAS_EXPIRED",
|
|
733
|
+
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
880
734
|
]);
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
clientId: z.string(),
|
|
891
|
-
scope: z.string().default(OAUTH_SCOPE)
|
|
892
|
-
}).loose();
|
|
893
|
-
const ProfileAuthMethod = z.enum(["oauth", "apiKey"]);
|
|
894
|
-
const ProfileRecord = z.object({
|
|
895
|
-
name: z.string(),
|
|
896
|
-
url: z.string(),
|
|
897
|
-
apiKey: z.string().nullable(),
|
|
898
|
-
oauth: ProfileOAuth.nullable().default(null),
|
|
899
|
-
lastProbe: ProfileLastProbe.nullable(),
|
|
900
|
-
lastFailure: ProfileLastFailure.nullable()
|
|
901
|
-
}).loose();
|
|
902
|
-
const ProfilesFile = z.object({
|
|
903
|
-
profiles: z.array(ProfileRecord),
|
|
904
|
-
defaultProfile: z.string().nullable().default(null)
|
|
905
|
-
}).loose();
|
|
906
|
-
function profileAuthMethod(record) {
|
|
907
|
-
return record.oauth !== null ? "oauth" : "apiKey";
|
|
735
|
+
function buildNetworkError(error, method, url) {
|
|
736
|
+
const fallback = errorMessage(error);
|
|
737
|
+
const code = causeCode(error);
|
|
738
|
+
const target = networkTarget(url);
|
|
739
|
+
return new NetworkError(networkMessage(code, target, fallback), {
|
|
740
|
+
method,
|
|
741
|
+
url,
|
|
742
|
+
cause: code ?? fallback
|
|
743
|
+
});
|
|
908
744
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const LEGACY_REJECTIONS_FILE = "rejections.json";
|
|
916
|
-
const PROFILES_FILE_MODE = 384;
|
|
917
|
-
const PROFILES_DIR_MODE = 448;
|
|
918
|
-
const DEFAULT_PROFILE = "default";
|
|
919
|
-
const LEGACY_STORAGE_NOTICE = "Old profile storage detected and ignored; re-run `mb auth login` for each profile.";
|
|
920
|
-
const KEYCHAIN_RESIDUAL_NOTICE = "warning: could not remove one or more secrets from the OS keychain; a stored token may remain — remove it manually or retry.";
|
|
921
|
-
const account = {
|
|
922
|
-
profileApiKey: (profile) => `profile:${profile}:apiKey`,
|
|
923
|
-
profileOAuthAccess: (profile) => `profile:${profile}:oauthAccess`,
|
|
924
|
-
profileOAuthRefresh: (profile) => `profile:${profile}:oauthRefresh`
|
|
925
|
-
};
|
|
926
|
-
let legacyWarningPending = false;
|
|
927
|
-
let keychainResidualPending = false;
|
|
928
|
-
let keyringDowngradeNotice = null;
|
|
929
|
-
function profilesFilePath() {
|
|
930
|
-
return join(configDir(), PROFILES_FILE);
|
|
745
|
+
function networkMessage(code, target, fallback) {
|
|
746
|
+
if (code === null) return `Could not reach Metabase: ${fallback}`;
|
|
747
|
+
if (TLS_ERROR_CODES.has(code)) return `Could not reach Metabase: TLS error contacting ${target.host} (${code}) — the certificate could not be verified, or https:// was used against a plain-HTTP server. For a certificate the OS does not trust either, set NODE_EXTRA_CA_CERTS to its CA bundle.`;
|
|
748
|
+
const hint = NETWORK_HINTS[code];
|
|
749
|
+
if (hint === void 0) return `Could not reach Metabase: ${fallback} (${code})`;
|
|
750
|
+
return `Could not reach Metabase: ${hint(target)}`;
|
|
931
751
|
}
|
|
932
|
-
function
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
return
|
|
752
|
+
function causeCode(error) {
|
|
753
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
754
|
+
if (cause instanceof Error && "code" in cause && typeof cause.code === "string") return cause.code;
|
|
755
|
+
return null;
|
|
936
756
|
}
|
|
937
|
-
function
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
757
|
+
function networkTarget(url) {
|
|
758
|
+
const parsed = new URL(url);
|
|
759
|
+
return {
|
|
760
|
+
host: parsed.host,
|
|
761
|
+
hostname: parsed.hostname
|
|
762
|
+
};
|
|
941
763
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
764
|
+
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/core/http/retry.ts
|
|
767
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
768
|
+
const BASE_DELAY_MS = 250;
|
|
769
|
+
const MAX_DELAY_MS = 8e3;
|
|
770
|
+
const MS_PER_SECOND = 1e3;
|
|
771
|
+
function backoffDelay({ attempt, retryAfterHeader }) {
|
|
772
|
+
const fromHeader = parseRetryAfter(retryAfterHeader);
|
|
773
|
+
if (fromHeader !== null) return fromHeader;
|
|
774
|
+
return Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
|
|
946
775
|
}
|
|
947
|
-
function
|
|
948
|
-
|
|
776
|
+
function parseRetryAfter(header) {
|
|
777
|
+
if (!header) return null;
|
|
778
|
+
const numeric = Number(header);
|
|
779
|
+
if (Number.isFinite(numeric) && numeric >= 0) return Math.round(numeric * MS_PER_SECOND);
|
|
780
|
+
const dateMs = Date.parse(header);
|
|
781
|
+
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
782
|
+
return null;
|
|
949
783
|
}
|
|
950
|
-
function
|
|
951
|
-
|
|
952
|
-
try {
|
|
953
|
-
new Entry(KEYRING_SERVICE, key).setPassword(value);
|
|
954
|
-
return true;
|
|
955
|
-
} catch {
|
|
956
|
-
return false;
|
|
957
|
-
}
|
|
784
|
+
function sleep(ms, signal) {
|
|
785
|
+
return setTimeout(ms, void 0, { signal });
|
|
958
786
|
}
|
|
959
|
-
function
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
787
|
+
async function runWithRetries(attempt, signal) {
|
|
788
|
+
let attemptIndex = 0;
|
|
789
|
+
while (true) {
|
|
790
|
+
const outcome = await attempt(attemptIndex);
|
|
791
|
+
if (outcome.kind === "success") return outcome.response;
|
|
792
|
+
await sleep(outcome.delayMs, signal);
|
|
793
|
+
attemptIndex += 1;
|
|
965
794
|
}
|
|
966
795
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
796
|
+
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/core/http/client.ts
|
|
799
|
+
const UNAUTHORIZED_STATUS = 401;
|
|
800
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
801
|
+
const OCTET_STREAM_CONTENT_TYPE = "application/octet-stream";
|
|
802
|
+
const TEXT_CONTENT_TYPE_PREFIX = "text/";
|
|
803
|
+
const ERROR_BODY_BYTE_CAP = 64 * 1024;
|
|
804
|
+
const USER_AGENT = `metabase-cli/${package_default.version}`;
|
|
805
|
+
const IDEMPOTENT_METHODS = new Set([
|
|
806
|
+
"GET",
|
|
807
|
+
"HEAD",
|
|
808
|
+
"OPTIONS"
|
|
809
|
+
]);
|
|
810
|
+
const NO_SERVER_TAG = async () => null;
|
|
811
|
+
function createClient(config, overrides = {}) {
|
|
812
|
+
const fetchImpl = overrides.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
813
|
+
const getServerTag = overrides.getServerTag ?? NO_SERVER_TAG;
|
|
814
|
+
const refreshCredential = overrides.refreshCredential;
|
|
815
|
+
let credential = config.credential;
|
|
816
|
+
const knownSecrets = new Set(credentialSecrets(credential));
|
|
817
|
+
const redactionContext = { knownSecrets };
|
|
818
|
+
let refreshInFlight = null;
|
|
819
|
+
function tryRefreshCredential() {
|
|
820
|
+
const refresh = refreshCredential;
|
|
821
|
+
if (credential.kind !== "oauth" || refresh === void 0) return Promise.resolve(false);
|
|
822
|
+
refreshInFlight ??= refreshOnce(refresh).finally(() => {
|
|
823
|
+
refreshInFlight = null;
|
|
824
|
+
});
|
|
825
|
+
return refreshInFlight;
|
|
973
826
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
|
|
827
|
+
async function refreshOnce(refresh) {
|
|
828
|
+
const next = await refresh();
|
|
829
|
+
if (next === null) return false;
|
|
830
|
+
credential = next;
|
|
831
|
+
for (const secret of credentialSecrets(next)) knownSecrets.add(secret);
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
async function attemptOnce(prepared, attempt) {
|
|
835
|
+
const hasRetriesLeft = attempt < prepared.retries;
|
|
836
|
+
const timeoutSignal = AbortSignal.timeout(prepared.timeoutMs);
|
|
837
|
+
const { combined, processSignal } = combineAborts(timeoutSignal, prepared.callerSignal);
|
|
838
|
+
let response;
|
|
839
|
+
try {
|
|
840
|
+
response = await fetchImpl(prepared.url, {
|
|
841
|
+
method: prepared.method,
|
|
842
|
+
headers: prepared.headers,
|
|
843
|
+
body: prepared.body,
|
|
844
|
+
signal: combined
|
|
845
|
+
});
|
|
846
|
+
} catch (error) {
|
|
847
|
+
throwIfAborted(prepared.callerSignal, processSignal);
|
|
848
|
+
if (hasRetriesLeft) return {
|
|
849
|
+
kind: "retry",
|
|
850
|
+
delayMs: backoffDelay({ attempt })
|
|
851
|
+
};
|
|
852
|
+
if (timeoutSignal.aborted) throw new TimeoutError(`Request timed out after ${prepared.timeoutMs}ms`, {
|
|
853
|
+
kind: "http",
|
|
854
|
+
method: prepared.method,
|
|
855
|
+
url: prepared.url,
|
|
856
|
+
timeoutMs: prepared.timeoutMs
|
|
857
|
+
});
|
|
858
|
+
throw buildNetworkError(error, prepared.method, prepared.url);
|
|
859
|
+
}
|
|
860
|
+
const canRetryStatus = hasRetriesLeft && prepared.idempotent;
|
|
861
|
+
if (!response.ok && isRetryableStatus(response.status) && canRetryStatus) {
|
|
862
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
863
|
+
response.body?.cancel().catch(() => void 0);
|
|
983
864
|
return {
|
|
984
|
-
|
|
985
|
-
|
|
865
|
+
kind: "retry",
|
|
866
|
+
delayMs: backoffDelay({
|
|
867
|
+
attempt,
|
|
868
|
+
retryAfterHeader: retryAfter
|
|
869
|
+
})
|
|
986
870
|
};
|
|
987
871
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
872
|
+
if (!response.ok) {
|
|
873
|
+
const rawBody = await readBodyForError(response);
|
|
874
|
+
const serverTag = await getServerTag();
|
|
875
|
+
throw new HttpError({
|
|
876
|
+
status: response.status,
|
|
877
|
+
statusText: response.statusText,
|
|
878
|
+
method: prepared.method,
|
|
879
|
+
url: prepared.url,
|
|
880
|
+
responseHeaders: response.headers,
|
|
881
|
+
rawBody,
|
|
882
|
+
serverTag,
|
|
883
|
+
redactionContext
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
assertContentType(response, prepared);
|
|
994
887
|
return {
|
|
995
|
-
|
|
996
|
-
|
|
888
|
+
kind: "success",
|
|
889
|
+
response
|
|
997
890
|
};
|
|
998
891
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
async function detectLegacyArtifacts() {
|
|
1002
|
-
const legacyCredentials = join(configDir(), LEGACY_CREDENTIALS_FILE);
|
|
1003
|
-
const legacyRejections = join(configDir(), LEGACY_REJECTIONS_FILE);
|
|
1004
|
-
const [credentialsExists, rejectionsExists] = await Promise.all([fileExists(legacyCredentials), fileExists(legacyRejections)]);
|
|
1005
|
-
if (credentialsExists || rejectionsExists) legacyWarningPending = true;
|
|
1006
|
-
}
|
|
1007
|
-
async function fileExists(path) {
|
|
1008
|
-
try {
|
|
1009
|
-
await promises.access(path);
|
|
1010
|
-
return true;
|
|
1011
|
-
} catch {
|
|
1012
|
-
return false;
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
async function writeProfilesFile(file) {
|
|
1016
|
-
const path = profilesFilePath();
|
|
1017
|
-
if (file.profiles.length === 0) {
|
|
1018
|
-
await promises.unlink(path).catch(() => void 0);
|
|
1019
|
-
await cleanupLegacyFiles();
|
|
1020
|
-
return;
|
|
892
|
+
async function executeRaw(prepared) {
|
|
893
|
+
return runWithRetries((attempt) => attemptOnce(prepared, attempt), prepared.callerSignal);
|
|
1021
894
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
895
|
+
async function executeWithAuthRefresh(path, opts) {
|
|
896
|
+
const prepared = prepare(path, opts);
|
|
897
|
+
try {
|
|
898
|
+
return {
|
|
899
|
+
response: await executeRaw(prepared),
|
|
900
|
+
prepared
|
|
901
|
+
};
|
|
902
|
+
} catch (error) {
|
|
903
|
+
if (error instanceof HttpError && error.status === UNAUTHORIZED_STATUS) {
|
|
904
|
+
if (await tryRefreshCredential()) {
|
|
905
|
+
const retried = prepare(path, opts);
|
|
906
|
+
return {
|
|
907
|
+
response: await executeRaw(retried),
|
|
908
|
+
prepared: retried
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
1034
914
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
account: key,
|
|
1055
|
-
reason: keyringEnabled() ? "unavailable" : "disabled"
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
function keyringFallbackWarning(location) {
|
|
1059
|
-
const cause = location.reason === "disabled" ? `OS keychain disabled via ${ENV_DISABLE_KEYRING}` : "OS keychain unavailable";
|
|
1060
|
-
return `warning: ${cause}; credentials stored as plaintext at ${location.path}`;
|
|
1061
|
-
}
|
|
1062
|
-
function persistSecret(key, value) {
|
|
1063
|
-
if (trySetKeyring(key, value)) return {
|
|
1064
|
-
backend: "keyring",
|
|
1065
|
-
service: KEYRING_SERVICE,
|
|
1066
|
-
account: key
|
|
1067
|
-
};
|
|
1068
|
-
removeKeyringEntry(key);
|
|
1069
|
-
return fileLocation(key);
|
|
1070
|
-
}
|
|
1071
|
-
function resolveSecret(key, inline) {
|
|
1072
|
-
if (inline !== null) return inline;
|
|
1073
|
-
return tryReadKeyring(key) ?? null;
|
|
1074
|
-
}
|
|
1075
|
-
async function readProfileCredential(name = DEFAULT_PROFILE) {
|
|
1076
|
-
const file = await readProfilesFile();
|
|
1077
|
-
const record = findRecord(file, name);
|
|
1078
|
-
if (record === null) return null;
|
|
1079
|
-
return resolveRecordCredential(record);
|
|
1080
|
-
}
|
|
1081
|
-
function resolveRecordCredential(record) {
|
|
1082
|
-
if (record.oauth !== null) {
|
|
1083
|
-
const accessToken = resolveSecret(account.profileOAuthAccess(record.name), record.oauth.accessToken);
|
|
1084
|
-
const refreshToken = resolveSecret(account.profileOAuthRefresh(record.name), record.oauth.refreshToken);
|
|
1085
|
-
if (accessToken === null || refreshToken === null) return null;
|
|
915
|
+
function prepare(path, opts = {}) {
|
|
916
|
+
const method = opts.method ?? "GET";
|
|
917
|
+
const expectContentType = opts.expectContentType ?? "json";
|
|
918
|
+
const url = buildUrl(config.url, path, opts.query);
|
|
919
|
+
const headers = new Headers();
|
|
920
|
+
const auth = credentialAuthHeader(credential);
|
|
921
|
+
headers.set(auth.name, auth.value);
|
|
922
|
+
headers.set("accept", acceptHeader(expectContentType));
|
|
923
|
+
headers.set("user-agent", USER_AGENT);
|
|
924
|
+
let body = null;
|
|
925
|
+
if (opts.body !== void 0 && opts.body !== null) if (typeof opts.body === "string" || opts.body instanceof URLSearchParams) body = opts.body;
|
|
926
|
+
else if (opts.body instanceof FormData || opts.body instanceof ReadableStream) body = opts.body;
|
|
927
|
+
else if (opts.body instanceof Uint8Array) {
|
|
928
|
+
body = opts.body;
|
|
929
|
+
headers.set("content-type", OCTET_STREAM_CONTENT_TYPE);
|
|
930
|
+
} else {
|
|
931
|
+
body = JSON.stringify(opts.body);
|
|
932
|
+
headers.set("content-type", JSON_CONTENT_TYPE);
|
|
933
|
+
}
|
|
1086
934
|
return {
|
|
1087
|
-
url
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
935
|
+
url,
|
|
936
|
+
method,
|
|
937
|
+
headers,
|
|
938
|
+
body,
|
|
939
|
+
expectContentType,
|
|
940
|
+
retries: opts.retries ?? DEFAULT_MAX_RETRIES,
|
|
941
|
+
idempotent: opts.idempotent ?? IDEMPOTENT_METHODS.has(method),
|
|
942
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
943
|
+
callerSignal: opts.signal
|
|
1096
944
|
};
|
|
1097
945
|
}
|
|
1098
|
-
const apiKey = resolveSecret(account.profileApiKey(record.name), record.apiKey);
|
|
1099
|
-
if (apiKey === null) return null;
|
|
1100
946
|
return {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
lastFailure: null
|
|
1137
|
-
} : {
|
|
1138
|
-
...existing,
|
|
1139
|
-
url: profile.url,
|
|
1140
|
-
apiKey: inlineApiKey,
|
|
1141
|
-
oauth: null
|
|
1142
|
-
};
|
|
1143
|
-
await upsertRecord(file, name, updated);
|
|
1144
|
-
return location;
|
|
1145
|
-
}
|
|
1146
|
-
async function writeOAuthProfile(url, credential, name = DEFAULT_PROFILE) {
|
|
1147
|
-
const accessKey = account.profileOAuthAccess(name);
|
|
1148
|
-
const refreshKey = account.profileOAuthRefresh(name);
|
|
1149
|
-
const accessLocation = persistSecret(accessKey, credential.accessToken);
|
|
1150
|
-
const refreshLocation = persistSecret(refreshKey, credential.refreshToken);
|
|
1151
|
-
const onFile = accessLocation.backend === "file" || refreshLocation.backend === "file";
|
|
1152
|
-
const oauth = {
|
|
1153
|
-
accessToken: onFile ? credential.accessToken : null,
|
|
1154
|
-
refreshToken: onFile ? credential.refreshToken : null,
|
|
1155
|
-
expiresAt: credential.expiresAt,
|
|
1156
|
-
clientId: credential.clientId,
|
|
1157
|
-
scope: credential.scope
|
|
1158
|
-
};
|
|
1159
|
-
const file = await readProfilesFile();
|
|
1160
|
-
const existing = findRecord(file, name);
|
|
1161
|
-
if (onFile && existing !== null && sideIsKeyringBacked(existing, "oauth")) keyringDowngradeNotice = keyringFallbackWarning(fileLocation(accessKey));
|
|
1162
|
-
flagResidualIfUnconfirmed(existing, "apiKey", [removeKeyringEntry(account.profileApiKey(name))]);
|
|
1163
|
-
const updated = existing === null ? {
|
|
1164
|
-
name,
|
|
1165
|
-
url,
|
|
1166
|
-
apiKey: null,
|
|
1167
|
-
oauth,
|
|
1168
|
-
lastProbe: null,
|
|
1169
|
-
lastFailure: null
|
|
1170
|
-
} : {
|
|
1171
|
-
...existing,
|
|
1172
|
-
url,
|
|
1173
|
-
apiKey: null,
|
|
1174
|
-
oauth
|
|
1175
|
-
};
|
|
1176
|
-
await upsertRecord(file, name, updated);
|
|
1177
|
-
return onFile ? fileLocation(accessKey) : {
|
|
1178
|
-
backend: "keyring",
|
|
1179
|
-
service: KEYRING_SERVICE,
|
|
1180
|
-
account: accessKey
|
|
1181
|
-
};
|
|
1182
|
-
}
|
|
1183
|
-
async function writeProbeResult(name, input) {
|
|
1184
|
-
const probe = ProfileLastProbe.parse({
|
|
1185
|
-
at: new Date().toISOString(),
|
|
1186
|
-
version: input.server.version,
|
|
1187
|
-
tokenFeatures: input.server.tokenFeatures,
|
|
1188
|
-
user: input.user
|
|
1189
|
-
});
|
|
1190
|
-
const file = await readProfilesFile();
|
|
1191
|
-
const existing = findRecord(file, name);
|
|
1192
|
-
if (existing === null) return null;
|
|
1193
|
-
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
1194
|
-
...entry,
|
|
1195
|
-
lastProbe: probe,
|
|
1196
|
-
lastFailure: null
|
|
1197
|
-
} : entry);
|
|
1198
|
-
await writeProfilesFile({
|
|
1199
|
-
...file,
|
|
1200
|
-
profiles
|
|
1201
|
-
});
|
|
1202
|
-
return probe;
|
|
1203
|
-
}
|
|
1204
|
-
async function writeProbeFailure(name, input) {
|
|
1205
|
-
const failure$1 = ProfileLastFailure.parse({
|
|
1206
|
-
at: new Date().toISOString(),
|
|
1207
|
-
kind: input.kind,
|
|
1208
|
-
reason: input.reason
|
|
1209
|
-
});
|
|
1210
|
-
const file = await readProfilesFile();
|
|
1211
|
-
const existing = findRecord(file, name);
|
|
1212
|
-
if (existing === null) return null;
|
|
1213
|
-
const profiles = file.profiles.map((entry) => entry.name === name ? {
|
|
1214
|
-
...entry,
|
|
1215
|
-
lastFailure: failure$1
|
|
1216
|
-
} : entry);
|
|
1217
|
-
await writeProfilesFile({
|
|
1218
|
-
...file,
|
|
1219
|
-
profiles
|
|
1220
|
-
});
|
|
1221
|
-
return failure$1;
|
|
947
|
+
async requestRaw(path, opts) {
|
|
948
|
+
return (await executeWithAuthRefresh(path, opts ?? {})).response;
|
|
949
|
+
},
|
|
950
|
+
async requestParsed(schema, path, opts) {
|
|
951
|
+
const { response, prepared } = await executeWithAuthRefresh(path, {
|
|
952
|
+
...opts,
|
|
953
|
+
expectContentType: "json"
|
|
954
|
+
});
|
|
955
|
+
const text = await response.text();
|
|
956
|
+
try {
|
|
957
|
+
return parseJson(text, schema, { source: prepared.url });
|
|
958
|
+
} catch (error) {
|
|
959
|
+
if (error instanceof ValidationError) throw new ResponseShapeError({
|
|
960
|
+
method: prepared.method,
|
|
961
|
+
url: prepared.url,
|
|
962
|
+
status: response.status,
|
|
963
|
+
zodIssues: error.developerDetail.zodIssues,
|
|
964
|
+
serverTag: await getServerTag()
|
|
965
|
+
});
|
|
966
|
+
throw error;
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
async requestStream(path, opts) {
|
|
970
|
+
const { response, prepared } = await executeWithAuthRefresh(path, {
|
|
971
|
+
...opts,
|
|
972
|
+
expectContentType: opts?.expectContentType ?? "binary"
|
|
973
|
+
});
|
|
974
|
+
if (!response.body) throw new NetworkError("Response had no body to stream", {
|
|
975
|
+
method: prepared.method,
|
|
976
|
+
url: prepared.url,
|
|
977
|
+
cause: "missing body"
|
|
978
|
+
});
|
|
979
|
+
return response.body;
|
|
980
|
+
}
|
|
981
|
+
};
|
|
1222
982
|
}
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
const
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
if (existing === null) return false;
|
|
1233
|
-
await writeProfilesFile({
|
|
1234
|
-
...file,
|
|
1235
|
-
profiles: file.profiles.filter((entry) => entry.name !== name),
|
|
1236
|
-
defaultProfile: file.defaultProfile === name ? null : file.defaultProfile
|
|
1237
|
-
});
|
|
1238
|
-
return true;
|
|
983
|
+
function buildUrl(baseUrl, path, query) {
|
|
984
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
985
|
+
const target = new URL(baseUrl + normalizedPath);
|
|
986
|
+
if (query) for (const [key, value] of Object.entries(query)) {
|
|
987
|
+
if (value === void 0) continue;
|
|
988
|
+
if (Array.isArray(value)) for (const entry of value) target.searchParams.append(key, String(entry));
|
|
989
|
+
else target.searchParams.append(key, String(value));
|
|
990
|
+
}
|
|
991
|
+
return target.toString();
|
|
1239
992
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
return
|
|
993
|
+
function acceptHeader(expected) {
|
|
994
|
+
if (expected === "json") return JSON_CONTENT_TYPE;
|
|
995
|
+
if (expected === "text") return "text/*";
|
|
996
|
+
return "*/*";
|
|
1243
997
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
998
|
+
function assertContentType(response, prepared) {
|
|
999
|
+
if (prepared.expectContentType === "binary") return;
|
|
1000
|
+
const contentType = response.headers.get("content-type");
|
|
1001
|
+
if (contentType === null) throwContentTypeMismatch(response, prepared, prepared.expectContentType);
|
|
1002
|
+
if (prepared.expectContentType === "json" && !contentType.includes(JSON_CONTENT_TYPE)) throwContentTypeMismatch(response, prepared, "json");
|
|
1003
|
+
if (prepared.expectContentType === "text" && !contentType.startsWith(TEXT_CONTENT_TYPE_PREFIX)) throwContentTypeMismatch(response, prepared, "text");
|
|
1004
|
+
}
|
|
1005
|
+
function throwContentTypeMismatch(response, prepared, expected) {
|
|
1006
|
+
const actual = response.headers.get("content-type") ?? "no content-type";
|
|
1007
|
+
throw new HttpError({
|
|
1008
|
+
status: response.status,
|
|
1009
|
+
statusText: response.statusText,
|
|
1010
|
+
method: prepared.method,
|
|
1011
|
+
url: prepared.url,
|
|
1012
|
+
responseHeaders: response.headers,
|
|
1013
|
+
rawBody: null,
|
|
1014
|
+
overrideUserMessage: `Expected ${expected} response but got ${actual}`
|
|
1249
1015
|
});
|
|
1250
1016
|
}
|
|
1017
|
+
async function readBodyForError(response) {
|
|
1018
|
+
try {
|
|
1019
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1020
|
+
return buffer.subarray(0, ERROR_BODY_BYTE_CAP).toString("utf8");
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
return `[body read failed: ${errorMessage(error)}]`;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1251
1025
|
|
|
1252
1026
|
//#endregion
|
|
1253
1027
|
//#region src/core/version/probe.ts
|
|
@@ -1265,19 +1039,219 @@ async function probeServer(client, opts = {}) {
|
|
|
1265
1039
|
};
|
|
1266
1040
|
}
|
|
1267
1041
|
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/core/url.ts
|
|
1044
|
+
function normalizeUrl(input) {
|
|
1045
|
+
const trimmed = input.trim().replace(/\/+$/, "");
|
|
1046
|
+
if (!/^https?:\/\//i.test(trimmed)) throw new ConfigError("URL must start with http:// or https://");
|
|
1047
|
+
return trimmed;
|
|
1048
|
+
}
|
|
1049
|
+
function displayUrl(input) {
|
|
1050
|
+
const parsed = new URL(input);
|
|
1051
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
1052
|
+
return `${parsed.origin}${path}`;
|
|
1053
|
+
}
|
|
1054
|
+
const LOOPBACK_HOSTS = new Set([
|
|
1055
|
+
"localhost",
|
|
1056
|
+
"127.0.0.1",
|
|
1057
|
+
"::1",
|
|
1058
|
+
"[::1]"
|
|
1059
|
+
]);
|
|
1060
|
+
function isLoopbackHost(hostname) {
|
|
1061
|
+
return LOOPBACK_HOSTS.has(hostname.toLowerCase());
|
|
1062
|
+
}
|
|
1063
|
+
function assertEndpointOrigin(endpoint, baseUrl, label) {
|
|
1064
|
+
let parsed;
|
|
1065
|
+
try {
|
|
1066
|
+
parsed = new URL(endpoint);
|
|
1067
|
+
} catch {
|
|
1068
|
+
throw new ConfigError(`OAuth ${label} is not a valid URL: ${endpoint}`);
|
|
1069
|
+
}
|
|
1070
|
+
const base = new URL(baseUrl);
|
|
1071
|
+
if (parsed.protocol !== "https:" && !isLoopbackHost(parsed.hostname)) throw new ConfigError(`OAuth ${label} must use https: ${endpoint}`);
|
|
1072
|
+
if (parsed.origin !== base.origin) throw new ConfigError(`OAuth ${label} origin (${parsed.origin}) does not match the Metabase URL (${base.origin})`);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/core/http/oauth.ts
|
|
1077
|
+
const DISCOVERY_PATH = "/.well-known/oauth-authorization-server";
|
|
1078
|
+
const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
|
|
1079
|
+
const OAUTH_TIMEOUT_MS = 3e4;
|
|
1080
|
+
async function oauthFetch(request) {
|
|
1081
|
+
const timeoutSignal = AbortSignal.timeout(OAUTH_TIMEOUT_MS);
|
|
1082
|
+
try {
|
|
1083
|
+
return await fetch(request.url, {
|
|
1084
|
+
method: request.method,
|
|
1085
|
+
headers: {
|
|
1086
|
+
...request.headers,
|
|
1087
|
+
"user-agent": USER_AGENT
|
|
1088
|
+
},
|
|
1089
|
+
...request.body !== void 0 ? { body: request.body } : {},
|
|
1090
|
+
signal: timeoutSignal
|
|
1091
|
+
});
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (timeoutSignal.aborted) throw new TimeoutError(`OAuth request timed out after ${OAUTH_TIMEOUT_MS}ms`, {
|
|
1094
|
+
kind: "http",
|
|
1095
|
+
method: request.method,
|
|
1096
|
+
url: request.url,
|
|
1097
|
+
timeoutMs: OAUTH_TIMEOUT_MS
|
|
1098
|
+
});
|
|
1099
|
+
throw buildNetworkError(error, request.method, request.url);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const OAUTH_SCOPE = "mb:full";
|
|
1103
|
+
const OAuthServerMetadata = z.object({
|
|
1104
|
+
issuer: z.string(),
|
|
1105
|
+
authorization_endpoint: z.string(),
|
|
1106
|
+
token_endpoint: z.string(),
|
|
1107
|
+
registration_endpoint: z.string().optional(),
|
|
1108
|
+
revocation_endpoint: z.string().optional(),
|
|
1109
|
+
scopes_supported: z.array(z.string()).optional()
|
|
1110
|
+
}).loose();
|
|
1111
|
+
const RegisteredClient = z.object({ client_id: z.string() }).loose();
|
|
1112
|
+
const OAuthTokens = z.object({
|
|
1113
|
+
access_token: z.string(),
|
|
1114
|
+
token_type: z.string(),
|
|
1115
|
+
expires_in: z.number().int().optional(),
|
|
1116
|
+
refresh_token: z.string().optional()
|
|
1117
|
+
}).loose();
|
|
1118
|
+
const OAuthErrorBody = z.object({
|
|
1119
|
+
error: z.string(),
|
|
1120
|
+
error_description: z.string().optional()
|
|
1121
|
+
}).loose();
|
|
1122
|
+
async function readJson(response, schema, source) {
|
|
1123
|
+
let raw;
|
|
1124
|
+
try {
|
|
1125
|
+
raw = await response.json();
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
throw new ConfigError(`${source}: invalid JSON response (${errorMessage(error)})`);
|
|
1128
|
+
}
|
|
1129
|
+
const parsed = schema.safeParse(raw);
|
|
1130
|
+
if (!parsed.success) throw new ConfigError(`${source}: unexpected response shape`);
|
|
1131
|
+
return parsed.data;
|
|
1132
|
+
}
|
|
1133
|
+
async function failure(response, source, method, url) {
|
|
1134
|
+
const rawBody = await response.text().catch(() => "");
|
|
1135
|
+
if (isRetryableStatus(response.status)) return new HttpError({
|
|
1136
|
+
status: response.status,
|
|
1137
|
+
statusText: response.statusText,
|
|
1138
|
+
method,
|
|
1139
|
+
url,
|
|
1140
|
+
responseHeaders: response.headers,
|
|
1141
|
+
rawBody
|
|
1142
|
+
});
|
|
1143
|
+
const parsed = parseJsonResult(rawBody, OAuthErrorBody);
|
|
1144
|
+
if (parsed.ok) {
|
|
1145
|
+
const detail = parsed.value.error_description ?? parsed.value.error;
|
|
1146
|
+
return new ConfigError(`${source} failed (${response.status}): ${detail}`);
|
|
1147
|
+
}
|
|
1148
|
+
return new ConfigError(`${source} failed (${response.status})`);
|
|
1149
|
+
}
|
|
1150
|
+
async function postForm(url, params, schema, source) {
|
|
1151
|
+
const response = await oauthFetch({
|
|
1152
|
+
url,
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
headers: {
|
|
1155
|
+
"content-type": FORM_CONTENT_TYPE,
|
|
1156
|
+
accept: JSON_CONTENT_TYPE
|
|
1157
|
+
},
|
|
1158
|
+
body: new URLSearchParams(params)
|
|
1159
|
+
});
|
|
1160
|
+
if (!response.ok) throw await failure(response, source, "POST", url);
|
|
1161
|
+
return readJson(response, schema, source);
|
|
1162
|
+
}
|
|
1163
|
+
const OAUTH_UNSUPPORTED_MESSAGE = "this Metabase does not support OAuth login (requires Metabase v63 or newer)";
|
|
1164
|
+
async function tryDiscoverMetadata(baseUrl) {
|
|
1165
|
+
const url = `${baseUrl}${DISCOVERY_PATH}`;
|
|
1166
|
+
const response = await oauthFetch({
|
|
1167
|
+
url,
|
|
1168
|
+
method: "GET",
|
|
1169
|
+
headers: { accept: JSON_CONTENT_TYPE }
|
|
1170
|
+
});
|
|
1171
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1172
|
+
if (!response.ok || !contentType.includes("json")) {
|
|
1173
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
const metadata = await readJson(response, OAuthServerMetadata, "OAuth discovery");
|
|
1177
|
+
const scopes = metadata.scopes_supported;
|
|
1178
|
+
if (scopes !== void 0 && !scopes.includes(OAUTH_SCOPE)) return null;
|
|
1179
|
+
assertEndpointOrigin(metadata.issuer, baseUrl, "issuer");
|
|
1180
|
+
assertEndpointOrigin(metadata.authorization_endpoint, baseUrl, "authorization endpoint");
|
|
1181
|
+
assertEndpointOrigin(metadata.token_endpoint, baseUrl, "token endpoint");
|
|
1182
|
+
if (metadata.registration_endpoint !== void 0) assertEndpointOrigin(metadata.registration_endpoint, baseUrl, "registration endpoint");
|
|
1183
|
+
if (metadata.revocation_endpoint !== void 0) assertEndpointOrigin(metadata.revocation_endpoint, baseUrl, "revocation endpoint");
|
|
1184
|
+
return metadata;
|
|
1185
|
+
}
|
|
1186
|
+
async function discoverMetadata(baseUrl) {
|
|
1187
|
+
const metadata = await tryDiscoverMetadata(baseUrl);
|
|
1188
|
+
if (metadata === null) throw new ConfigError(OAUTH_UNSUPPORTED_MESSAGE);
|
|
1189
|
+
return metadata;
|
|
1190
|
+
}
|
|
1191
|
+
async function registerClient(input) {
|
|
1192
|
+
const response = await oauthFetch({
|
|
1193
|
+
url: input.registrationEndpoint,
|
|
1194
|
+
method: "POST",
|
|
1195
|
+
headers: {
|
|
1196
|
+
"content-type": JSON_CONTENT_TYPE,
|
|
1197
|
+
accept: JSON_CONTENT_TYPE
|
|
1198
|
+
},
|
|
1199
|
+
body: JSON.stringify({
|
|
1200
|
+
application_type: "native",
|
|
1201
|
+
client_name: input.clientName,
|
|
1202
|
+
redirect_uris: [input.redirectUri],
|
|
1203
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1204
|
+
response_types: ["code"],
|
|
1205
|
+
token_endpoint_auth_method: "none",
|
|
1206
|
+
scope: OAUTH_SCOPE
|
|
1207
|
+
})
|
|
1208
|
+
});
|
|
1209
|
+
if (!response.ok) throw await failure(response, "OAuth client registration", "POST", input.registrationEndpoint);
|
|
1210
|
+
return readJson(response, RegisteredClient, "OAuth client registration");
|
|
1211
|
+
}
|
|
1212
|
+
function exchangeCode(input) {
|
|
1213
|
+
return postForm(input.tokenEndpoint, {
|
|
1214
|
+
grant_type: "authorization_code",
|
|
1215
|
+
code: input.code,
|
|
1216
|
+
redirect_uri: input.redirectUri,
|
|
1217
|
+
client_id: input.clientId,
|
|
1218
|
+
code_verifier: input.codeVerifier
|
|
1219
|
+
}, OAuthTokens, "OAuth token exchange");
|
|
1220
|
+
}
|
|
1221
|
+
function refreshTokens(input) {
|
|
1222
|
+
return postForm(input.tokenEndpoint, {
|
|
1223
|
+
grant_type: "refresh_token",
|
|
1224
|
+
refresh_token: input.refreshToken,
|
|
1225
|
+
client_id: input.clientId
|
|
1226
|
+
}, OAuthTokens, "OAuth token refresh");
|
|
1227
|
+
}
|
|
1228
|
+
async function revokeToken(input) {
|
|
1229
|
+
const response = await oauthFetch({
|
|
1230
|
+
url: input.revocationEndpoint,
|
|
1231
|
+
method: "POST",
|
|
1232
|
+
headers: { "content-type": FORM_CONTENT_TYPE },
|
|
1233
|
+
body: new URLSearchParams({
|
|
1234
|
+
token: input.token,
|
|
1235
|
+
client_id: input.clientId
|
|
1236
|
+
})
|
|
1237
|
+
});
|
|
1238
|
+
if (!response.ok) throw await failure(response, "OAuth token revocation", "POST", input.revocationEndpoint);
|
|
1239
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1268
1242
|
//#endregion
|
|
1269
1243
|
//#region src/core/auth/oauth-session.ts
|
|
1270
1244
|
async function refreshOAuthCredential(baseUrl, credential, nowMs) {
|
|
1271
|
-
const metadata = await discoverMetadata(baseUrl
|
|
1245
|
+
const metadata = await discoverMetadata(baseUrl);
|
|
1272
1246
|
const tokens = await refreshTokens({
|
|
1273
1247
|
tokenEndpoint: metadata.token_endpoint,
|
|
1274
1248
|
refreshToken: credential.refreshToken,
|
|
1275
1249
|
clientId: credential.clientId
|
|
1276
1250
|
});
|
|
1277
|
-
return oauthCredentialFromTokens(tokens, tokens.refresh_token ?? credential.refreshToken, credential.clientId,
|
|
1251
|
+
return oauthCredentialFromTokens(tokens, tokens.refresh_token ?? credential.refreshToken, credential.clientId, nowMs);
|
|
1278
1252
|
}
|
|
1279
1253
|
async function revokeOAuthCredential(baseUrl, credential) {
|
|
1280
|
-
const metadata = await discoverMetadata(baseUrl
|
|
1254
|
+
const metadata = await discoverMetadata(baseUrl);
|
|
1281
1255
|
const revocationEndpoint = metadata.revocation_endpoint;
|
|
1282
1256
|
if (revocationEndpoint === void 0) return false;
|
|
1283
1257
|
await revokeToken({
|
|
@@ -1298,10 +1272,8 @@ async function revokeOAuthCredential(baseUrl, credential) {
|
|
|
1298
1272
|
function isPreflightSkipped() {
|
|
1299
1273
|
return readEnv(ENV_SKIP_PREFLIGHT) === "1";
|
|
1300
1274
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
if (explicit !== null) return explicit;
|
|
1304
|
-
return await readDefaultProfileName() ?? DEFAULT_PROFILE;
|
|
1275
|
+
function resolveProfileName(profileFlag) {
|
|
1276
|
+
return explicitProfileName(profileFlag) ?? DEFAULT_PROFILE;
|
|
1305
1277
|
}
|
|
1306
1278
|
function explicitProfileName(profileFlag) {
|
|
1307
1279
|
return profileFlag || readEnv(ENV_PROFILE) || null;
|
|
@@ -1313,7 +1285,7 @@ function readEnvCredentials() {
|
|
|
1313
1285
|
};
|
|
1314
1286
|
}
|
|
1315
1287
|
async function resolveConfig(flags) {
|
|
1316
|
-
const profile =
|
|
1288
|
+
const profile = resolveProfileName(flags.profile);
|
|
1317
1289
|
const env = readEnvCredentials();
|
|
1318
1290
|
const hasUrl = Boolean(flags.url ?? env.url);
|
|
1319
1291
|
const hasKey = Boolean(flags.apiKey ?? env.apiKey);
|
|
@@ -1643,8 +1615,6 @@ function defineMetabaseCommand(def) {
|
|
|
1643
1615
|
getResolvedConfig,
|
|
1644
1616
|
getServerInfo
|
|
1645
1617
|
});
|
|
1646
|
-
} catch (error) {
|
|
1647
|
-
throw enrichScopeForbiddenError(error, cachedConfig);
|
|
1648
1618
|
} finally {
|
|
1649
1619
|
emitPendingWarnings();
|
|
1650
1620
|
}
|
|
@@ -1663,12 +1633,6 @@ function defineMetabaseCommand(def) {
|
|
|
1663
1633
|
});
|
|
1664
1634
|
return cmd;
|
|
1665
1635
|
}
|
|
1666
|
-
function enrichScopeForbiddenError(error, config) {
|
|
1667
|
-
if (!(error instanceof HttpError) || error.status !== 403 || config === null) return error;
|
|
1668
|
-
const { credential } = config;
|
|
1669
|
-
if (credential.kind !== "oauth" || credential.scope === OAUTH_SCOPE) return error;
|
|
1670
|
-
return new ConfigError(`${error.userMessage} This profile's login is scoped to ${credential.scope}, which only allows workspace commands against this server. Run \`mb auth login\` for a full-access login, or run content commands against the workspace itself via its ws-<id> profile.`);
|
|
1671
|
-
}
|
|
1672
1636
|
function emitPendingWarnings() {
|
|
1673
1637
|
for (const message of consumeLegacyEnvWarnings()) warn(message);
|
|
1674
1638
|
const legacy = consumeLegacyStorageWarning();
|
|
@@ -1728,4 +1692,4 @@ function buildConfigFlags(ctx) {
|
|
|
1728
1692
|
}
|
|
1729
1693
|
|
|
1730
1694
|
//#endregion
|
|
1731
|
-
export { DEFAULT_PROFILE, HttpError, OAUTH_SCOPE, ParsedVersionSchema, ProbedUser, ProfileAuthMethod, ProfileLastFailure, TokenFeatures, USER_AGENT,
|
|
1695
|
+
export { DEFAULT_PROFILE, HttpError, OAUTH_SCOPE, ParsedVersionSchema, ProbedUser, ProfileAuthMethod, ProfileLastFailure, TokenFeatures, USER_AGENT, clearProfile, combineAborts, consumeKeychainResidualWarning, consumeKeyringDowngradeWarning, createClient, createCredentialRefresher, defineMetabaseCommand, discoverMetadata, displayUrl, exchangeCode, explicitProfileName, keyringFallbackWarning, listProfileRecords, normalizeUrl, oauthCredentialFromTokens, parseCsv, parseEnum, parseEnumCsv, parseInteger, parseJson, parseJsonOrPlain, probeServer, profileAuthMethod, readEnvCredentials, readProfileCredential, readProfileRecord, registerClient, resolveProfileName, resolveRecordCredential, revokeOAuthCredential, throwIfAborted, tryDiscoverMetadata, writeOAuthProfile, writeProbeFailure, writeProbeResult, writeProfile };
|