diff --git a/.changeset/chilly-suns-dress.md b/.changeset/chilly-suns-dress.md new file mode 100644 index 0000000000..e8c0c5abe7 --- /dev/null +++ b/.changeset/chilly-suns-dress.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add provisioning to card diff --git a/.changeset/gentle-cases-fold.md b/.changeset/gentle-cases-fold.md new file mode 100644 index 0000000000..d35b2f747c --- /dev/null +++ b/.changeset/gentle-cases-fold.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ add wallet provisioning diff --git a/.changeset/rich-months-double.md b/.changeset/rich-months-double.md new file mode 100644 index 0000000000..9ede42ebec --- /dev/null +++ b/.changeset/rich-months-double.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🍱 add meawallet config assets diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..9bd3c48eac --- /dev/null +++ b/.npmrc @@ -0,0 +1,15 @@ +# isc license +# +# copyright meawallet +# +# permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby +# granted, provided that the above copyright notice and this permission notice appear in all copies. +# +# the software is provided "as is" and the author disclaims all warranties with regard to this software including +# all implied warranties of merchantability and fitness. in no event shall the author be liable for any special, +# direct, indirect, or consequential damages or any damages whatsoever resulting from loss of use, data or profits, +# whether in an action of contract, negligence or other tortious action, arising out of or in connection with the +# use or performance of this software. +@meawallet:registry=https://nexus.ext.meawallet.com/repository/react-native-mpp/ +//nexus.ext.meawallet.com/repository/react-native-mpp/:username=ext-react-native-mpp +//nexus.ext.meawallet.com/repository/react-native-mpp/:_password=OXJDTVo1ZEg2dHVD diff --git a/app.config.ts b/app.config.ts index 1dbe60c115..2e66450bd0 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,7 +1,17 @@ import type { PluginConfigType as BuildPropertiesConfig } from "expo-build-properties/build/pluginConfig"; import type { FontProps } from "expo-font/plugin/build/withFonts"; -import { AndroidConfig, withAndroidManifest, withAppBuildGradle, type ConfigPlugin } from "expo/config-plugins"; +import { + AndroidConfig, + IOSConfig, + withAndroidManifest, + withAppBuildGradle, + withDangerousMod, + withXcodeProject, + type ConfigPlugin, +} from "expo/config-plugins"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { env } from "node:process"; import metadata from "./package.json"; @@ -40,6 +50,7 @@ export default { associatedDomains: [`webcredentials:${env.APP_DOMAIN ?? "sandbox.exactly.app"}`], supportsTablet: false, buildNumber: String(versionCode), + entitlements: { "com.apple.developer.payment-pass-provisioning": true }, infoPlist: { ITSAppUsesNonExemptEncryption: false, CFBundleAllowMixedLocalizations: true, @@ -110,6 +121,110 @@ export default { }, ], // @ts-expect-error inline plugin + ((config) => { + const withAndroid = withDangerousMod(config, [ + "android", + (c) => { + const source = path.join(c.modRequest.projectRoot, "src/assets/mea_config"); + const destination = path.join(c.modRequest.projectRoot, "android/app/src/main/assets/mea_config"); + mkdirSync(path.dirname(destination), { recursive: true }); + if (existsSync(source)) copyFileSync(source, destination); + return c; + }, + ]); + return withXcodeProject(withAndroid, (c) => { + const source = path.join(c.modRequest.projectRoot, "src/assets/mea_config"); + const projectName = c.modRequest.projectName ?? ""; + const destination = path.join(c.modRequest.projectRoot, "ios", projectName, "mea_config"); + if (existsSync(source)) { + copyFileSync(source, destination); + IOSConfig.XcodeUtils.addResourceFileToGroup({ + filepath: `${projectName}/mea_config`, + groupName: projectName, + project: c.modResults, // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- expo xcode project type + isBuildFile: true, + }); + } + return c; + }); + }) satisfies ConfigPlugin, + // @ts-expect-error inline plugin + ((config) => + withDangerousMod(config, [ + "android", + (c) => { + const buildGradle = path.join(c.modRequest.projectRoot, "android/build.gradle"); + const meaRepo = ` maven { + url "https://nexus.ext.meawallet.com/repository/mpp-android-group/" + credentials { + username = "${env.MEAWALLET_ANDROID_USER}" + password = "${env.MEAWALLET_ANDROID_PASS}" + } + }`; + const contents = readFileSync(buildGradle, "utf8"); + if (!contents.includes("nexus.ext.meawallet.com")) { + const replaced = contents.replace(/(allprojects[\s\S]*?repositories\s*\{)/, `$1\n${meaRepo}`); // cspell:ignore allprojects + if (replaced === contents) + throw new Error("meawallet: failed to inject maven repo into android/build.gradle"); + writeFileSync(buildGradle, replaced); + } + return c; + }, + ])) satisfies ConfigPlugin, + // @ts-expect-error inline plugin + ((config) => + withDangerousMod(config, [ + "ios", + (c) => { + const podfile = path.join(c.modRequest.projectRoot, "ios/Podfile"); // cspell:ignore podfile Podfile OBJC RCTJS RCTUI modulemap fmodule + if (!existsSync(podfile)) return c; + const workaround = ` rctHeaders = "#{installer.sandbox.root}/Headers/Public/React-RCTAppDelegate" + Dir.mkdir(rctHeaders) unless Dir.exist?(rctHeaders) + File.write("#{rctHeaders}/React-RCTAppDelegate-umbrella.h", <<~'H') + #ifdef __OBJC__ + #import + #endif + #import "RCTAppDelegate.h" + #import "RCTAppSetupUtils.h" + #import "RCTArchConfiguratorProtocol.h" + #import "RCTDefaultReactNativeFactoryDelegate.h" + #import "RCTDependencyProvider.h" + #import "RCTJSRuntimeConfiguratorProtocol.h" + #import "RCTReactNativeFactory.h" + #import "RCTRootViewFactory.h" + #import "RCTUIConfiguratorProtocol.h" + H + File.write("#{rctHeaders}/React_RCTAppDelegate.modulemap", <<~MAP) + module React_RCTAppDelegate { + umbrella header "React-RCTAppDelegate-umbrella.h" + export * + module * { export * } + } + MAP + installer.pods_project.targets.each do |target| + next unless target.name == "meawallet-react-native-mpp" + target.build_configurations.each do |buildConfiguration| + flags = buildConfiguration.build_settings["OTHER_SWIFT_FLAGS"] || "$(inherited)" + next if flags.include?("React_RCTAppDelegate.modulemap") + buildConfiguration.build_settings["OTHER_SWIFT_FLAGS"] = + "#{flags} -Xcc -fmodule-map-file=\${PODS_ROOT}/Headers/Public/React-RCTAppDelegate/React_RCTAppDelegate.modulemap" + end + end +`; + const contents = readFileSync(podfile, "utf8"); + if (!contents.includes("React_RCTAppDelegate.modulemap")) { + const replaced = contents.replace( + /(\s{4}react_native_post_install\([\s\S]*?\n\s{4}\)\n)/, + `$1${workaround}`, + ); + if (replaced === contents) + throw new Error("meawallet: failed to inject react_native_post_install workaround into ios/Podfile"); + writeFileSync(podfile, replaced); + } + return c; + }, + ])) satisfies ConfigPlugin, + // @ts-expect-error inline plugin ((config) => withAndroidManifest( withAppBuildGradle(config, (c) => { diff --git a/cspell.json b/cspell.json index 54e7eee97a..db737408f3 100644 --- a/cspell.json +++ b/cspell.json @@ -100,6 +100,7 @@ "mainqueg", "mateo-soso", "mdpi", + "meawallet", "memester", "miniapp", "mipd", diff --git a/package.json b/package.json index ed17636189..bb72d300c5 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@intercom/intercom-react-native": "^9.8.0", "@intercom/messenger-js-sdk": "^0.0.18", "@lifi/sdk": "3.7.7", + "@meawallet/react-native-mpp": "^2.2.2", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/webcrypto": "^1.5.0", @@ -167,6 +168,9 @@ "packageManager": "pnpm@10.33.0", "pnpm": { "overrides": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "eslint-plugin-n": "17.24.0", "@wagmi/core": "catalog:", "abitype>zod": "^4.0.0", "comlink": "$comlink", @@ -202,6 +206,7 @@ }, "patchedDependencies": { "@lifi/sdk": "patches/@lifi__sdk.patch", + "@meawallet/react-native-mpp": "patches/@meawallet__react-native-mpp.patch", "embedded-postgres": "patches/embedded-postgres.patch", "eslint-config-universe": "patches/eslint-config-universe.patch" }, diff --git a/patches/@meawallet__react-native-mpp.patch b/patches/@meawallet__react-native-mpp.patch new file mode 100644 index 0000000000..bd205d9daf --- /dev/null +++ b/patches/@meawallet__react-native-mpp.patch @@ -0,0 +1,10 @@ +diff --git a/meawallet-react-native-mpp.podspec b/meawallet-react-native-mpp.podspec +index 05795f1b..7396ff2b 100644 +--- a/meawallet-react-native-mpp.podspec ++++ b/meawallet-react-native-mpp.podspec +@@ -21,4 +21,5 @@ Pod::Spec.new do |s| + s.xcconfig = { "GCC_PREPROCESSOR_DEFINITIONS" => "REACT_NATIVE_MPP_VERSION=\"@\\\"#{s.version}\\\"\"" } + + install_modules_dependencies(s) ++ s.dependency "React-RCTAppDelegate" + end diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 442a3d2b4a..b9d5332447 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ catalogs: version: 3.6.0 overrides: + '@typescript-eslint/eslint-plugin': 8.58.0 + '@typescript-eslint/parser': 8.58.0 + eslint-plugin-n: 17.24.0 '@wagmi/core': ^3.4.1 abitype>zod: ^4.0.0 comlink: ^4.4.2 @@ -102,6 +105,9 @@ patchedDependencies: '@lifi/sdk': hash: ee16233f297d9a6c8a8320b5dc2b4bf47b7be8b481d79e3d54108cd77775b45b path: patches/@lifi__sdk.patch + '@meawallet/react-native-mpp': + hash: 1865bf0eddf3da8572a839f14e03cf34cf4753191421a4bc8704f22c8cdaa33b + path: patches/@meawallet__react-native-mpp.patch embedded-postgres: hash: cb5e37525b1810f2af136570b38d5e0cec4cc2455408896ed1943d27f3f61b38 path: patches/embedded-postgres.patch @@ -152,6 +158,9 @@ importers: '@lifi/sdk': specifier: 3.7.7 version: 3.7.7(patch_hash=ee16233f297d9a6c8a8320b5dc2b4bf47b7be8b481d79e3d54108cd77775b45b)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + '@meawallet/react-native-mpp': + specifier: ^2.2.2 + version: 2.2.2(patch_hash=1865bf0eddf3da8572a839f14e03cf34cf4753191421a4bc8704f22c8cdaa33b)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0) '@peculiar/asn1-ecc': specifier: ^2.6.1 version: 2.6.1 @@ -410,7 +419,7 @@ importers: version: 22.6.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.4)(nx@22.6.2) '@nx/eslint-plugin': specifier: 22.6.2 - version: 22.6.2(@babel/traverse@7.29.0)(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(nx@22.6.2)(typescript@5.9.3) + version: 22.6.2(@babel/traverse@7.29.0)(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(nx@22.6.2)(typescript@5.9.3) '@nx/js': specifier: 22.6.2 version: 22.6.2(@babel/traverse@7.29.0)(nx@22.6.2) @@ -561,7 +570,7 @@ importers: version: 0.2.22 '@nx/eslint-plugin': specifier: ^22.6.2 - version: 22.6.2(@babel/traverse@7.29.0)(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(nx@22.6.2)(typescript@5.9.3) + version: 22.6.2(@babel/traverse@7.29.0)(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(nx@22.6.2)(typescript@5.9.3) '@tanstack/eslint-plugin-query': specifier: ^5.95.2 version: 5.95.2(eslint@9.39.4)(typescript@5.9.3) @@ -573,7 +582,7 @@ importers: version: 3.0.1 '@vitest/eslint-plugin': specifier: ^1.6.13 - version: 1.6.13(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)(vitest@4.1.2) + version: 1.6.13(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)(vitest@4.1.2) '@wagmi/cli': specifier: ^2.10.0 version: 2.10.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -591,7 +600,7 @@ importers: version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + version: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) eslint-plugin-jsdoc: specifier: ^62.8.1 version: 62.8.1(eslint@9.39.4) @@ -599,7 +608,7 @@ importers: specifier: ^6.10.2 version: 6.10.2(eslint@9.39.4) eslint-plugin-n: - specifier: ^17.24.0 + specifier: 17.24.0 version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-perfectionist: specifier: ^5.7.0 @@ -3586,6 +3595,13 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@meawallet/react-native-mpp@2.2.2': + resolution: {integrity: sha512-3y0Lc99HZsxffPtQrAE/fCp7PnBXT7DdoBbW9g4XCUZA7owv5KqbcZKh6Zl/2tgzF4Vu5olPkjJ4tPvr3s8Z7Q==} + engines: {node: '>=18'} + peerDependencies: + react: '*' + react-native: '*' + '@mermaid-js/parser@1.0.1': resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} @@ -3713,7 +3729,7 @@ packages: '@nx/eslint-plugin@22.6.2': resolution: {integrity: sha512-wrq+MwZ2QErQdm7XiI1jLSsJ658Yg7sR12gZLTyRfKvTFZIqBMQrOBd8v1IaRc+ZLUUiv9hqhzteLm/+EGAYuA==} peerDependencies: - '@typescript-eslint/parser': ^6.13.2 || ^7.0.0 || ^8.0.0 + '@typescript-eslint/parser': 8.58.0 eslint-config-prettier: ^10.0.0 peerDependenciesMeta: eslint-config-prettier: @@ -6112,16 +6128,16 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.57.2': - resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.57.2 + '@typescript-eslint/parser': 8.58.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ^5.9.3 - '@typescript-eslint/parser@8.57.2': - resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -6139,6 +6155,12 @@ packages: peerDependencies: typescript: ^5.9.3 + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: ^5.9.3 + '@typescript-eslint/scope-manager@8.56.1': resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6147,6 +6169,10 @@ packages: resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6159,6 +6185,12 @@ packages: peerDependencies: typescript: ^5.9.3 + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: ^5.9.3 + '@typescript-eslint/type-utils@8.57.2': resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6166,6 +6198,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ^5.9.3 + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ^5.9.3 + '@typescript-eslint/types@8.56.1': resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6174,6 +6213,10 @@ packages: resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6186,6 +6229,12 @@ packages: peerDependencies: typescript: ^5.9.3 + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: ^5.9.3 + '@typescript-eslint/utils@8.56.1': resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6200,6 +6249,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ^5.9.3 + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ^5.9.3 + '@typescript-eslint/visitor-keys@8.56.1': resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6208,6 +6264,10 @@ packages: resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -6335,7 +6395,7 @@ packages: resolution: {integrity: sha512-ui7JGWBoQpS5NKKW0FDb1eTuFEZ5EupEv2Psemuyfba7DfA5K52SeDLelt6P4pQJJ/4UGkker/BgMk/KrjH3WQ==} engines: {node: '>=18'} peerDependencies: - '@typescript-eslint/eslint-plugin': '*' + '@typescript-eslint/eslint-plugin': 8.58.0 eslint: '>=8.57.0' typescript: ^5.9.3 vitest: '*' @@ -16916,6 +16976,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@meawallet/react-native-mpp@2.2.2(patch_hash=1865bf0eddf3da8572a839f14e03cf34cf4753191421a4bc8704f22c8cdaa33b)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)': + dependencies: + react: 19.2.0 + react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@5.0.10) + '@mermaid-js/parser@1.0.1': dependencies: langium: 4.2.1 @@ -17084,12 +17149,12 @@ snapshots: - supports-color - verdaccio - '@nx/eslint-plugin@22.6.2(@babel/traverse@7.29.0)(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(nx@22.6.2)(typescript@5.9.3)': + '@nx/eslint-plugin@22.6.2(@babel/traverse@7.29.0)(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(nx@22.6.2)(typescript@5.9.3)': dependencies: '@nx/devkit': 22.6.2(nx@22.6.2) '@nx/js': 22.6.2(@babel/traverse@7.29.0)(nx@22.6.2) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) chalk: 4.1.2 @@ -20409,14 +20474,14 @@ snapshots: '@types/node': 25.5.0 optional: true - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 eslint: 9.39.4 ignore: 7.0.5 natural-compare: 1.4.0 @@ -20425,12 +20490,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 eslint: 9.39.4 typescript: 5.9.3 @@ -20439,8 +20504,8 @@ snapshots: '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -20455,6 +20520,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 @@ -20465,6 +20539,11 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -20473,6 +20552,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.57.2 @@ -20485,10 +20568,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.56.1': {} '@typescript-eslint/types@8.57.2': {} + '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) @@ -20519,6 +20616,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.56.1(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -20541,6 +20653,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 @@ -20551,6 +20674,11 @@ snapshots: '@typescript-eslint/types': 8.57.2 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -20635,13 +20763,13 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/ui@4.1.2)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/eslint-plugin@1.6.13(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)(vitest@4.1.2)': + '@vitest/eslint-plugin@1.6.13(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)(vitest@4.1.2)': dependencies: '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) typescript: 5.9.3 vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/ui@4.1.2)(vite@8.0.5(@types/node@25.5.0)(esbuild@0.27.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: @@ -22834,11 +22962,11 @@ snapshots: eslint-config-universe@15.0.3(patch_hash=cfe35ddf48bd80b3df25a30d7d8878394712a39321d2855dcb90a64df2ccc280)(@types/eslint@9.6.1)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4)(prettier@3.8.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) @@ -22880,15 +23008,15 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) @@ -22926,7 +23054,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -22937,7 +23065,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -22949,7 +23077,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -29216,8 +29344,8 @@ snapshots: typescript-eslint@8.57.2(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 diff --git a/server/api/card.ts b/server/api/card.ts index b4ba346c65..7a5812e569 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -13,6 +13,7 @@ import { nullable, number, object, + optional, parse, picklist, pipe, @@ -32,7 +33,17 @@ import database, { cards, credentials } from "../database"; import t from "../i18n"; import auth from "../middleware/auth"; import { sendPushNotification } from "../utils/onesignal"; -import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda"; +import { + autoCredit, + createCard, + getCard, + getPIN, + getProcessorDetails, + getSecrets, + getUser, + setPIN, + updateCard, +} from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { getAccount } from "../utils/persona"; import { customer } from "../utils/sardine"; @@ -70,6 +81,12 @@ const CardResponse = object({ ]), }), productId: pipe(string(), metadata({ examples: ["402"] })), + provisioning: optional( + object({ + processorCardId: pipe(string(), metadata({ examples: ["card_abc123"] })), + timeBasedSecret: pipe(string(), metadata({ examples: ["otp_xyz"] })), + }), + ), }); const CreatedCardResponse = object({ @@ -106,11 +123,23 @@ export default new Hono() "/", vValidator("header", object({ sessionid: string() }), validatorHook({ code: "bad session id", status: 400 })), auth(), + vValidator( + "query", + optional( + object({ + scope: optional(literal("provisioning")), + }), + {}, + ), + validatorHook(), + ), describeRoute({ summary: "Get card information", description: ` Retrieve the card profile and encrypted card data for an authenticated user. +Successful responses include push-provisioning credentials in the \`provisioning\` field only when the \`scope=provisioning\` query parameter is sent. + **Retrieving encrypted card details** 1. **Generate a session ID**: Encrypt a 32‑character hexadecimal secret (no spaces/dashes) with the provided public RSA key using RSA‑OAEP. 2. **Send the request**: Include the encrypted secret in the header \`sessionid\` when calling this endpoint. @@ -212,17 +241,53 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str const account = parse(Address, credential.account); setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); - if (credential.cards.length > 0 && credential.cards[0]) { - const { id, lastFour, status, mode, productId } = credential.cards[0]; - if (status === "DELETED") throw new Error("card deleted"); - const [{ expirationMonth, expirationYear, limit }, pan, user, pin] = await Promise.all([ - getCard(id), - getSecrets(id, c.req.valid("header").sessionid), - getUser(credential.pandaId).catch((error: unknown) => { - const issue = noUser(error); - if (!issue) throw error; - const shouldCapture = issue.error.status === 404 || status === "ACTIVE"; - if (shouldCapture) { + if (credential.cards.length === 0 || !credential.cards[0]) return c.json({ code: "no card" }, 404); + const { id, lastFour, status, mode, productId } = credential.cards[0]; + if (status === "DELETED") throw new Error("card deleted"); + const [{ expirationMonth, expirationYear, limit }, pan, user, pin, provisioning] = await Promise.all([ + getCard(id), + getSecrets(id, c.req.valid("header").sessionid), + getUser(credential.pandaId).catch((error: unknown) => { + const issue = noUser(error); + if (!issue) throw error; + const shouldCapture = issue.error.status === 404 || status === "ACTIVE"; + if (shouldCapture) { + withScope((scope) => { + scope.addEventProcessor((event) => { + if (event.exception?.values?.[0]) event.exception.values[0].type = issue.type; + return event; + }); + captureException(issue.error, { + level: "warning", + fingerprint: ["{{ default }}", issue.type], + extra: { + cardId: id, + credentialId, + pandaId: credential.pandaId, + status, + shouldCapture, + userIssue: issue.type, + }, + }); + }); + } + return null; + }), + getPIN(id, c.req.valid("header").sessionid), + c.req.valid("query").scope === "provisioning" + ? getProcessorDetails(id).catch((error: unknown) => { + if (error instanceof ServiceError && error.status === 404) { + withScope(() => { + captureException(error, { + level: "warning", + fingerprint: ["{{ default }}", "stale processor card"], + extra: { cardId: id, credentialId, pandaId: credential.pandaId, status }, + }); + }); + return { code: "no card" as const }; + } + const issue = noUser(error); + if (!issue) throw error; withScope((scope) => { scope.addEventProcessor((event) => { if (event.exception?.values?.[0]) event.exception.values[0].type = issue.type; @@ -236,34 +301,36 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str credentialId, pandaId: credential.pandaId, status, - shouldCapture, userIssue: issue.type, }, }); }); - } - return null; - }), - getPIN(id, c.req.valid("header").sessionid), - ]); - if (!user) return c.json({ code: "no panda" }, 403); - return c.json( - { - ...pan, - ...pin, - displayName: `${user.firstName} ${user.lastName}`, - expirationMonth, - expirationYear, - lastFour, - mode, - provider: "panda" as const, - status, - limit, - productId, - } satisfies InferOutput, - 200, - ); - } else return c.json({ code: "no card" }, 404); + return { code: "no panda" as const }; + }) + : null, + ]); + if (provisioning && "code" in provisioning && provisioning.code === "no card") + return c.json({ code: "no card" }, 404); + if (!user) return c.json({ code: "no panda" }, 403); + if (provisioning && "code" in provisioning) return c.json({ code: "no panda" }, 403); + c.header("Cache-Control", "no-store"); + return c.json( + { + ...pan, + ...pin, + displayName: `${user.firstName} ${user.lastName}`, + expirationMonth, + expirationYear, + lastFour, + mode, + provider: "panda" as const, + status, + limit, + productId, + ...(provisioning && !("code" in provisioning) ? { provisioning } : {}), + } satisfies InferOutput, + 200, + ); }, ) .post( diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 7f5c244905..fe66923df6 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -6,6 +6,7 @@ import "../mocks/pax"; import "../mocks/persona"; import { eq } from "drizzle-orm"; +import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { testClient } from "hono/testing"; import { parse } from "valibot"; @@ -26,6 +27,8 @@ import * as pax from "../../utils/pax"; import * as persona from "../../utils/persona"; import ServiceError from "../../utils/ServiceError"; +import type { UnofficialStatusCode } from "hono/utils/http-status"; + const appClient = testClient(app); describe("authenticated", () => { @@ -120,6 +123,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + const processorDetails = vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-default", + timeBasedSecret: "secret-default", + }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); @@ -129,6 +136,42 @@ describe("authenticated", () => { ); const json = await response.json(); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(json).toStrictEqual({ + ...panTemplate, + ...pinTemplate, + displayName: "First Last", + expirationMonth: "9", + expirationYear: "2029", + lastFour: "1234", + mode: 0, + provider: "panda", + status: "ACTIVE", + limit: { amount: 5000, frequency: "per24HourPeriod" }, + productId: PLATINUM_PRODUCT_ID, + }); + expect(processorDetails).not.toHaveBeenCalled(); + }); + + it("returns panda card provisioning when requested", async () => { + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + const processorDetails = vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-default", + timeBasedSecret: "secret-default", + }); + + vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + const json = await response.json(); + expect(response.status).toBe(200); expect(json).toStrictEqual({ ...panTemplate, @@ -142,7 +185,9 @@ describe("authenticated", () => { status: "ACTIVE", limit: { amount: 5000, frequency: "per24HourPeriod" }, productId: PLATINUM_PRODUCT_ID, + provisioning: { processorCardId: "proc-default", timeBasedSecret: "secret-default" }, }); + expect(processorDetails).toHaveBeenCalledExactlyOnceWith("default"); }); it("returns panda card with signature product id", async () => { @@ -151,6 +196,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + const processorDetails = vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-sig", + timeBasedSecret: "secret-sig", + }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); @@ -161,6 +210,7 @@ describe("authenticated", () => { const json = await response.json(); expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); expect(json).toStrictEqual({ ...panTemplate, ...pinTemplate, @@ -174,6 +224,7 @@ describe("authenticated", () => { limit: { amount: 5000, frequency: "per24HourPeriod" }, productId: SIGNATURE_PRODUCT_ID, }); + expect(processorDetails).not.toHaveBeenCalled(); }); it("returns 403 no panda when no panda customer", async () => { @@ -217,6 +268,10 @@ describe("authenticated", () => { "Not Found", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -240,6 +295,10 @@ describe("authenticated", () => { "User exists but is not approved yet", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -264,6 +323,10 @@ describe("authenticated", () => { "user exists but is not approved", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -280,6 +343,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockRejectedValueOnce(new ServiceError("Panda", 404, "", "NotFoundError")); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -296,6 +363,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockRejectedValueOnce(new HTTPException(500, { message: "unexpected panda failure" })); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -318,6 +389,10 @@ describe("authenticated", () => { "User exists, but is not approved", ), ); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -334,6 +409,10 @@ describe("authenticated", () => { vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockRejectedValueOnce(new HTTPException(500, { message: "internal server error" })); + vi.spyOn(panda, "getProcessorDetails").mockResolvedValueOnce({ + processorCardId: "proc-x", + timeBasedSecret: "secret-x", + }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -797,6 +876,130 @@ describe("authenticated", () => { expect(card?.status).toBe("DELETED"); }); + it("returns 404 when provisioning reports stale card", async () => { + const error = new ServiceError("Panda", 404, "not found"); + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(error); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + error, + expect.objectContaining({ + extra: { cardId: "default", credentialId: "default", pandaId: "default", status: "ACTIVE" }, + fingerprint: ["{{ default }}", "stale processor card"], + level: "warning", + }), + ); + }); + + it("returns 404 when provisioning reports stale card through parent onError", async () => { + const error = new ServiceError("Panda", 404, "not found"); + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(error); + + const server = new Hono().route("/api/card", app); + server.onError((_error, c) => + c.json({ code: "unexpected error", legacy: "unexpected error" }, 555 as UnofficialStatusCode), + ); + + const response = await server.request("http://example.com/api/card?scope=provisioning", { + headers: { sessionid: "fakeSession", "test-credential-id": "default" }, + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + }); + + it("returns 404 when provisioning reports stale card and user lookup fails", async () => { + const stale = new ServiceError("Panda", 404, "not found"); + const forbidden = new ServiceError( + "Panda", + 403, + '{"message":"User exists but is not approved yet","error":"ForbiddenError","statusCode":403}', + "ForbiddenError", + "User exists but is not approved yet", + ); + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockRejectedValueOnce(forbidden); + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(stale); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toStrictEqual({ code: "no card" }); + }); + + it("returns 403 when provisioning reports unapproved user", async () => { + const error = new ServiceError( + "Panda", + 403, + '{"message":"User exists but is not approved yet","error":"ForbiddenError","statusCode":403}', + "ForbiddenError", + "User exists but is not approved yet", + ); + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(error); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(captureException).toHaveBeenCalledExactlyOnceWith( + error, + expect.objectContaining({ + extra: { + cardId: "default", + credentialId: "default", + pandaId: "default", + status: "ACTIVE", + userIssue: "PandaForbidden", + }, + fingerprint: ["{{ default }}", "PandaForbidden"], + level: "warning", + }), + ); + }); + + it("returns 500 when provisioning reports unexpected error", async () => { + vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); + vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + vi.spyOn(panda, "getProcessorDetails").mockRejectedValueOnce(new ServiceError("Panda", 500, "internal error")); + + const response = await appClient.index.$get( + { header: { sessionid: "fakeSession" }, query: { scope: "provisioning" } }, + { headers: { "test-credential-id": "default" } }, + ); + + expect(response.status).toBe(500); + expect(panda.getProcessorDetails).toHaveBeenCalledWith("default"); + expect(captureException).not.toHaveBeenCalled(); + }); + describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 84b86b0a0e..a0bcf03717 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -86,6 +86,11 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => }), getCard: vi.fn().mockImplementation((cardId: string) => Promise.resolve(cards.get(cardId))), getPIN: vi.fn().mockResolvedValue({ pin: null }), + getProcessorDetails: vi + .fn() + .mockImplementation((cardId: string) => + Promise.resolve({ processorCardId: `proc_${cardId}`, timeBasedSecret: `secret_${cardId}` }), + ), getSecrets: vi.fn().mockImplementation((_cardId: string, sessionId: string) => { const privateKey = process.env.PANDA_E2E_PRIVATE_KEY; if (!privateKey) throw new Error("PANDA_E2E_PRIVATE_KEY not set"); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bcf..84dd85dafd 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -111,6 +111,13 @@ export async function getCard(cardId: string) { return await request(CardResponse, `/issuing/cards/${cardId}`); } +export function getProcessorDetails(cardId: string) { + return request( + object({ processorCardId: string(), timeBasedSecret: string() }), + `/issuing/cards/${cardId}/processorDetails`, + ); +} + export async function updateCard(card: { billing?: { city: string; diff --git a/src/assets/images/google-wallet-button.svg b/src/assets/images/google-wallet-button.svg new file mode 100644 index 0000000000..67e9346a7c --- /dev/null +++ b/src/assets/images/google-wallet-button.svg @@ -0,0 +1,12 @@ + + + + + + + + + + Add to + Google Wallet + diff --git a/src/assets/mea_config b/src/assets/mea_config new file mode 100644 index 0000000000..671a1be2db Binary files /dev/null and b/src/assets/mea_config differ diff --git a/src/components/card/CardDetails.tsx b/src/components/card/CardDetails.tsx index 11482621f3..458c2c5b01 100644 --- a/src/components/card/CardDetails.tsx +++ b/src/components/card/CardDetails.tsx @@ -12,7 +12,7 @@ import { useQuery } from "@tanstack/react-query"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; -import DismissableAlert from "./DismissableAlert"; +import WalletButtons from "./WalletButtons"; import ExaLogoDark from "../../assets/images/exa-logo-dark.svg"; import ExaLogoLight from "../../assets/images/exa-logo-light.svg"; import ExaLogoSignature from "../../assets/images/exa-logo-signature.svg"; @@ -20,7 +20,6 @@ import VisaLogoDark from "../../assets/images/visa-logo-dark.svg"; import VisaLogoLight from "../../assets/images/visa-logo-light.svg"; import VisaLogoSignature from "../../assets/images/visa-logo-signature.svg"; import { decrypt } from "../../utils/panda"; -import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import ModalSheet from "../shared/ModalSheet"; import SafeView from "../shared/SafeView"; @@ -34,7 +33,6 @@ export default function CardDetails({ open, onClose }: { onClose: () => void; op const theme = useColorScheme(); const toast = useToastController(); const { t } = useTranslation(); - const { data: alertShown } = useQuery({ queryKey: ["settings", "alertShown"] }); const { data: card, isPending } = useQuery({ queryKey: ["card", "details"] }); const [details, setDetails] = useState({ pan: "", cvc: "" }); useEffect(() => { @@ -171,14 +169,7 @@ export default function CardDetails({ open, onClose }: { onClose: () => void; op ) : null} - {card && alertShown ? ( - { - queryClient.setQueryData(["settings", "alertShown"], false); - }} - /> - ) : null} + {card ? : null} diff --git a/src/components/card/WalletButtons.tsx b/src/components/card/WalletButtons.tsx new file mode 100644 index 0000000000..794820b88c --- /dev/null +++ b/src/components/card/WalletButtons.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Pressable } from "react-native"; + +import { Spinner, XStack } from "tamagui"; + +import MeaPushProvisioning from "@meawallet/react-native-mpp"; + +import useWalletProvisioning from "./useWalletProvisioning"; +import GoogleWalletButton from "../../assets/images/google-wallet-button.svg"; + +export default function WalletButtons({ displayName, lastFour }: { displayName: string; lastFour: string }) { + const { eligible, provisioning, isPending, addToAppleWallet, addToGoogleWallet } = useWalletProvisioning( + lastFour, + displayName, + ); + + if (isPending || (!eligible?.apple && !eligible?.google)) return null; + + return ( + + {provisioning ? ( + + ) : ( + <> + {eligible.apple && ( + { + addToAppleWallet().catch(() => undefined); + }} + /> + )} + {eligible.google && ( + { + addToGoogleWallet().catch(() => undefined); + }} + > + + + )} + + )} + + ); +} diff --git a/src/components/card/WalletButtons.web.tsx b/src/components/card/WalletButtons.web.tsx new file mode 100644 index 0000000000..2e2de39229 --- /dev/null +++ b/src/components/card/WalletButtons.web.tsx @@ -0,0 +1,4 @@ +// eslint-disable-next-line @eslint-react/no-unused-props -- prop parity with native .tsx variant +export default function WalletButtons(_: { displayName: string; lastFour: string }) { + return null; +} diff --git a/src/components/card/useWalletProvisioning.ts b/src/components/card/useWalletProvisioning.ts new file mode 100644 index 0000000000..4f3faaabe7 --- /dev/null +++ b/src/components/card/useWalletProvisioning.ts @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { Platform } from "react-native"; + +import MeaPushProvisioning, { MppCardDataParameters } from "@meawallet/react-native-mpp"; +import { useQuery } from "@tanstack/react-query"; + +import reportError from "../../utils/reportError"; +import { getWalletCredentials } from "../../utils/server"; + +let initPromise: null | Promise = null; + +function initSdk() { + initPromise ??= MeaPushProvisioning.initialize().catch((error: unknown) => { + initPromise = null; + throw error; + }); + return initPromise; +} + +export default function useWalletProvisioning(lastFour: string, displayName: string) { + const [provisioning, setProvisioning] = useState(false); + + const { data: eligible, isPending } = useQuery({ + queryKey: ["wallet", "eligible", lastFour], + queryFn: async () => { + await initSdk(); + if (Platform.OS === "ios") { + const [available, canAdd] = await Promise.all([ + MeaPushProvisioning.ApplePay.canAddPaymentPass(), + MeaPushProvisioning.ApplePay.canAddPaymentPassWithPrimaryAccountNumberSuffix(lastFour), + ]); + return { apple: available && canAdd, google: false }; + } + if (Platform.OS === "android") { + return { apple: false, google: await MeaPushProvisioning.GooglePay.isWalletAvailable() }; + } + return { apple: false, google: false }; + }, + enabled: lastFour.length === 4, + }); + + async function provision(addToWallet: (cardData: MppCardDataParameters) => Promise) { + setProvisioning(true); + try { + await initSdk(); + const { cardId, cardSecret } = await getWalletCredentials(); + await addToWallet(MppCardDataParameters.withCardSecret(cardId, cardSecret)); + } catch (error) { + reportError(error); + } finally { + setProvisioning(false); + } + } + + return { + eligible, + provisioning, + isPending, + addToAppleWallet: () => + provision(async (cardData) => { + const response = await MeaPushProvisioning.ApplePay.initializeOemTokenization(cardData); + await MeaPushProvisioning.ApplePay.showAddPaymentPassView(response); + }), + addToGoogleWallet: () => provision((cardData) => MeaPushProvisioning.GooglePay.pushCard(cardData, displayName, {})), + }; +} diff --git a/src/utils/server.ts b/src/utils/server.ts index 52dc1139f7..e4f78d5f54 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -100,6 +100,19 @@ async function getCard() { queryClient.setQueryDefaults(["card", "details"], { queryFn: getCard }); export type CardDetails = Awaited>; +export async function getWalletCredentials() { + await auth(); + const { id } = await session(); + const response = await api.card.$get({ header: { sessionid: id }, query: { scope: "provisioning" } }); + if (!response.ok) { + const { code } = await response.json(); + throw new APIError(response.status, code); + } + const card = await response.json(); + if (!card.provisioning) throw new Error("bad card provisioning response"); + return { cardId: card.provisioning.processorCardId, cardSecret: card.provisioning.timeBasedSecret }; +} + async function getPIN() { const result = await getCard(); if (!result) return null;