diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index ea9e5b0532..8ff20153ff 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -24,6 +24,7 @@ {"ts":"2026-06-08T11:24:30.263Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-06-08T11:27:15.648Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/mutations/useSimulateActions/useSimulateActions.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-08T12:55:15.492Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-06-09T11:03:40.976Z","tool":"Write","file":"src/shared/api/daoService/domain/enum/network.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-09T11:56:43.815Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-06-09T11:57:24.436Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-06-09T11:57:39.250Z","tool":"Edit","file":"src/modules/governance/api/actionSimulationService/actionSimulationService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} @@ -31,7 +32,6 @@ {"ts":"2026-06-10T10:36:11.062Z","tool":"Edit","file":"src/modules/finance/dialogs/assetSelectionDialog/index.ts","rule":"dialog-conventions","bytes":3499,"elapsed_ms":2,"adapter":"generic"} {"ts":"2026-06-10T10:36:11.106Z","tool":"Edit","file":"src/modules/finance/constants/financeDialogsDefinitions.ts","rule":"dialog-conventions","bytes":3499,"elapsed_ms":1,"adapter":"generic"} {"ts":"2026-06-10T10:36:11.149Z","tool":"Edit","file":"src/plugins/tokenPlugin/index.ts","rule":"plugin-slot-registration","bytes":3795,"elapsed_ms":1,"adapter":"generic"} -{"ts":"2026-06-09T11:03:40.976Z","tool":"Write","file":"src/shared/api/daoService/domain/enum/network.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-23T12:49:35.098Z","tool":"Edit","file":"src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":0,"adapter":"claude"} {"ts":"2026-06-23T12:49:49.841Z","tool":"Edit","file":"src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-06-23T12:50:57.800Z","tool":"Edit","file":"src/shared/api/aragonBackendService/aragonBackendServiceError.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} @@ -106,10 +106,6 @@ {"ts":"2026-07-07T08:25:58.831Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T08:26:08.144Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-07-07T08:27:04.958Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.test.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} -{"ts":"2026-07-08T11:05:20.251Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":29,"adapter":"claude"} -{"ts":"2026-07-08T11:05:45.029Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":6,"adapter":"claude"} -{"ts":"2026-07-08T11:06:24.791Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T11:06:45.503Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-07T13:45:43.994Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:31:34.162Z","tool":"Write","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:31:49.364Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} @@ -118,29 +114,20 @@ {"ts":"2026-07-07T15:38:04.582Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:38:11.715Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-07-07T15:39:53.889Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-07-08T11:05:20.251Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":29,"adapter":"claude"} +{"ts":"2026-07-08T11:05:45.029Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":6,"adapter":"claude"} +{"ts":"2026-07-08T11:06:24.791Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-07-08T11:06:45.503Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-08T16:28:10.671Z","tool":"Write","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-08T16:29:33.169Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-08T16:29:42.858Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-08T16:29:55.658Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":11,"adapter":"claude"} {"ts":"2026-07-08T16:30:49.523Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-14T13:48:20.570Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":1,"adapter":"claude"} -{"ts":"2026-07-14T13:49:10.198Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-09T11:51:56.300Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-09T12:00:44.020Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-13T10:09:35.950Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-07T13:45:43.994Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:34.162Z","tool":"Write","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:49.364Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:51.306Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:31:55.733Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:38:04.582Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-07T15:38:11.715Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":1,"adapter":"claude"} -{"ts":"2026-07-07T15:39:53.889Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T16:28:10.671Z","tool":"Write","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T16:29:33.169Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-07-08T16:29:42.858Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} -{"ts":"2026-07-08T16:29:55.658Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":11,"adapter":"claude"} -{"ts":"2026-07-08T16:30:49.523Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-07-14T13:48:20.570Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-07-14T13:49:10.198Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-22T08:55:56.658Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/queries/useAllDaoPermissions/useAllDaoPermissions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":6,"adapter":"claude"} {"ts":"2026-07-24T17:37:00.673Z","tool":"Edit","file":"apps/app/src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-28T16:26:08.696Z","tool":"Edit","file":"apps/app/src/shared/components/transactionDialog/useManagedTransaction.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} @@ -154,9 +141,9 @@ {"ts":"2026-08-03T13:13:36.306Z","tool":"Edit","file":"apps/app/src/modules/governance/api/smartContractService/smartContractService.api.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-03T14:29:17.284Z","tool":"Edit","file":"apps/app/src/actions/core/createProposal/createProposalActionDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-03T14:29:22.784Z","tool":"Edit","file":"apps/app/src/actions/core/execute/executeActionDetails.tsx","rule":"core-action-decoded-input","bytes":948,"elapsed_ms":2,"adapter":"claude"} -{"ts":"2026-08-04T10:56:30.749Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/domain/pluginSettings.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T07:16:05.722Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedActionDecoded.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-08-04T07:16:07.165Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-08-04T10:56:30.749Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/domain/pluginSettings.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T12:34:00.962Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T12:34:05.191Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} {"ts":"2026-08-04T12:34:06.974Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":7,"adapter":"claude"} @@ -211,3 +198,28 @@ {"ts":"2026-08-31T11:16:41.725Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-08-31T11:16:50.965Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-31T11:17:03.502Z","tool":"Edit","file":".agents/shared/skills/rules/plugin-visibility.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:56:07.219Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T11:57:14.375Z","tool":"Edit","file":"apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:57:32.160Z","tool":"Edit","file":"apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:57:48.971Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:57:48.971Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T11:58:13.848Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T11:58:13.848Z","tool":"Edit","file":"apps/app/src/modules/governance/api/governanceService/governanceService.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:00.291Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:00.291Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:06.030Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:06.030Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:13:56.114Z","tool":"Edit","file":"apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T12:13:58.803Z","tool":"Edit","file":"apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T12:14:39.178Z","tool":"Write","file":"apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:22:08.858Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-09-01T12:22:11.722Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:22:28.911Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:22:28.911Z","tool":"Edit","file":"apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:26:08.994Z","tool":"Edit","file":"apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-01T12:26:11.643Z","tool":"Edit","file":"apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts","rule":"plugin-visibility","bytes":3517,"elapsed_ms":0,"adapter":"claude"} +{"ts":"2026-09-02T15:06:12.045Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:06:12.045Z","tool":"Edit","file":"apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:06:32.738Z","tool":"Edit","file":"apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts","rule":"test-scope-and-selectors","bytes":1727,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:07:11.649Z","tool":"Edit","file":"apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-09-02T15:07:20.587Z","tool":"Edit","file":"apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"} diff --git a/.changeset/sentry-audit-hardening.md b/.changeset/sentry-audit-hardening.md new file mode 100644 index 0000000000..ffcf49ce3b --- /dev/null +++ b/.changeset/sentry-audit-hardening.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Fix the Sentry-audit crash and noise findings: enrich SPP sub-proposals routed to lock-to-vote by their stage body so the proposals page no longer crashes on inconsistent backend data, render a not-found state on the create proposal/process routes for unknown plugin addresses, serve the 404 page (instead of a reported server error) for bot-probed DAO and proposal URLs, format dates after mount to stop SSR hydration mismatches on the members/proposals/proposal-details/dashboard pages, compare plugin daoAddress case-insensitively in useDaoPlugins, and classify environment noise (in-app browsers, wallet-extension conflicts, private-mode storage, deploy skew) as expected in the monitoring taxonomy. diff --git a/apps/app/jest.config.js b/apps/app/jest.config.js index b19fc33e6d..2eb784994c 100644 --- a/apps/app/jest.config.js +++ b/apps/app/jest.config.js @@ -25,6 +25,9 @@ const config = { // under src/. Pin next imports to the app's real package so tests are // unaffected by the shims' presence. '^next$': '/node_modules/next', + // Mirror the tsconfig `next/navigation-original` alias (the real next/navigation + // behind the client-hooks wrapper) for Jest's resolver. + '^next/navigation-original$': '/node_modules/next/navigation', '^next/(.*)$': '/node_modules/next/$1', // Package only exposes a `module` field (no `main`/`exports`), which Jest's // Node-style resolver doesn't understand unlike bundlers (webpack/Next.js). diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 377896a014..20f1b6c9f3 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -1268,6 +1268,13 @@ } }, "createProcessPage": { + "error": { + "action": "View settings", + "notFound": { + "description": "We couldn't find the governance process you're looking for.", + "title": "Process not found" + } + }, "finalStep": "Publish process", "steps": { "METADATA": { @@ -1976,6 +1983,13 @@ } }, "createProposalPage": { + "error": { + "action": "Explore proposals", + "notFound": { + "description": "We couldn't find the governance process you're looking for.", + "title": "Process not found" + } + }, "finalStep": "Publish proposal", "steps": { "ACTIONS": { diff --git a/apps/app/src/instrumentation.ts b/apps/app/src/instrumentation.ts index 97c8829096..33b0590705 100644 --- a/apps/app/src/instrumentation.ts +++ b/apps/app/src/instrumentation.ts @@ -1,3 +1,4 @@ +import { AragonBackendServiceError } from './shared/api/aragonBackendService'; import { monitoringUtils } from './shared/utils/monitoringUtils'; export async function register() { @@ -10,4 +11,17 @@ export async function register() { } } -export const onRequestError = monitoringUtils.logRequestError; +// Expected not-found lookups (bots and stale links probing removed DAO/plugin URLs) +// render 404-style states and are not reported — mirrors the suppression in the +// metadata utils and PageError for render paths without their own error handling. +export const onRequestError: typeof monitoringUtils.logRequestError = ( + error, + request, + context, +) => { + if (AragonBackendServiceError.isExpectedNotFoundError(error)) { + return; + } + + return monitoringUtils.logRequestError(error, request, context); +}; diff --git a/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts b/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts index 9c1bc1c4bd..22a73742f5 100644 --- a/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts +++ b/apps/app/src/modules/application/utils/applicationMetadataUtils/applicationMetadataUtils.ts @@ -73,10 +73,14 @@ class ApplicationMetadataUtils { image, }); } catch (error: unknown) { - // Suppress notFound / pluginNotFound: the page renders an empty/404 state - // for arbitrary URLs (bots, stale links to removed plugins) — not bugs, and - // would flood Sentry. - if (!AragonBackendServiceError.isExpectedNotFoundError(error)) { + // Suppress the errors that mean the URL points at nothing: the address/ENS comes + // straight from the URL, so a rejected identifier means an arbitrary URL (bots, + // stale links, malformed addresses) — not a bug, and would flood Sentry. A refused + // request (401/403/429) is a different story and still gets reported. + if ( + !AragonBackendServiceError.isExpectedNotFoundError(error) && + !AragonBackendServiceError.isUnresolvableResourceError(error) + ) { monitoringUtils.logError(error); } diff --git a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx index 3d0d13b10a..2aa4685742 100644 --- a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx +++ b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.test.tsx @@ -1,8 +1,15 @@ import { render, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as proposalPermissionGuard from '@/modules/governance/hooks/useProposalPermissionCheckGuard'; +import * as daoService from '@/shared/api/daoService'; import * as DialogProvider from '@/shared/components/dialogProvider'; -import { generateDialogContext } from '@/shared/testUtils'; +import * as useDaoPlugins from '@/shared/hooks/useDaoPlugins'; +import { + generateDao, + generateDialogContext, + generateFilterComponentPlugin, + generateReactQueryResultSuccess, +} from '@/shared/testUtils'; import { plausibleAnalyticsUtils } from '@/shared/utils/plausibleAnalyticsUtils'; import { GovernanceType, @@ -82,17 +89,25 @@ describe(' component', () => { proposalPermissionGuard, 'useProposalPermissionCheckGuard', ); + const useDaoPluginsSpy = jest.spyOn(useDaoPlugins, 'useDaoPlugins'); + const useDaoSpy = jest.spyOn(daoService, 'useDao'); const trackAnalyticsSpy = jest.spyOn(plausibleAnalyticsUtils, 'track'); beforeEach(() => { useDialogContextSpy.mockReturnValue(generateDialogContext()); useProposalPermissionCheckGuardSpy.mockImplementation(() => undefined); + useDaoPluginsSpy.mockReturnValue([generateFilterComponentPlugin()]); + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ data: generateDao() }), + ); trackAnalyticsSpy.mockImplementation(() => undefined); }); afterEach(() => { useDialogContextSpy.mockReset(); useProposalPermissionCheckGuardSpy.mockReset(); + useDaoPluginsSpy.mockReset(); + useDaoSpy.mockReset(); trackAnalyticsSpy.mockReset(); }); @@ -131,4 +146,17 @@ describe(' component', () => { }, }); }); + + it('renders a not-found state instead of the wizard when the plugin address matches no DAO plugin', () => { + useDaoPluginsSpy.mockReturnValue([]); + + renderPage(); + + expect( + screen.getByText( + 'app.createDao.createProcessPage.error.notFound.title', + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId('submit')).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx index 3a4f63b687..121e89845e 100644 --- a/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx +++ b/apps/app/src/modules/createDao/pages/createProcessPage/createProcessPageClient.tsx @@ -2,10 +2,15 @@ import { useMemo } from 'react'; import { useProposalPermissionCheckGuard } from '@/modules/governance/hooks/useProposalPermissionCheckGuard'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; +import { useDao } from '@/shared/api/daoService'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; import { WizardPage } from '@/shared/components/wizards/wizardPage'; +import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { errorUtils } from '@/shared/utils/errorUtils'; import { plausibleAnalyticsUtils } from '@/shared/utils/plausibleAnalyticsUtils'; import { GovernanceType, @@ -35,12 +40,47 @@ export const CreateProcessPageClient: React.FC< const { t } = useTranslations(); const { open } = useDialogContext(); + // Undefined when the DAO is not loaded or the plugin address is unknown (e.g. a stale + // link to an uninstalled process) — the page renders a not-found state in that case. + const plugin = useDaoPlugins({ + daoId, + pluginAddress, + includeLinkedAccounts: true, + })?.[0]?.meta; + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + useProposalPermissionCheckGuard({ daoId, pluginAddress, redirectTab: 'settings', }); + const processedSteps = useMemo( + () => + createProcessWizardSteps.map(({ meta, ...step }) => ({ + ...step, + meta: { ...meta, name: t(meta.name) }, + })), + [t], + ); + + if (plugin == null) { + const pluginNotFoundError = new AragonBackendServiceError( + AragonBackendServiceError.pluginNotFoundCode, + `CreateProcessPageClient: no plugin found for address ${pluginAddress}`, + 404, + ); + + return ( + + ); + } + const handleFormSubmit = (values: ICreateProcessFormData) => { const dialogParams: IPrepareProcessDialogParams = { daoId, @@ -61,15 +101,6 @@ export const CreateProcessPageClient: React.FC< open(CreateDaoDialogId.PREPARE_PROCESS, { params: dialogParams }); }; - const processedSteps = useMemo( - () => - createProcessWizardSteps.map(({ meta, ...step }) => ({ - ...step, - meta: { ...meta, name: t(meta.name) }, - })), - [t], - ); - return ( = ( useDaoPlugins({ daoId, type: PluginType.PROCESS, visibleOnly: true }) ?? []; + // Dates are formatted in the viewer's timezone while the server renders in UTC — + // render them only after mount to avoid a hydration mismatch. + const isMounted = useIsMounted(); + if (dao == null) { return null; } @@ -80,9 +85,11 @@ export const DaoDashboardPageClient: React.FC = ( const daoEns = daoUtils.getDaoEns(dao); const truncatedAddress = addressUtils.truncateAddress(dao.address); - const daoLaunchedAt = formatterUtils.formatDate(dao.blockTimestamp * 1000, { - format: DateFormat.YEAR_MONTH, - }); + const daoLaunchedAt = isMounted + ? formatterUtils.formatDate(dao.blockTimestamp * 1000, { + format: DateFormat.YEAR_MONTH, + }) + : '-'; const daoAddressLink = buildEntityUrl({ type: ChainEntityType.ADDRESS, diff --git a/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts b/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts index a6e433ca47..bac047c330 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceService.test.ts @@ -237,6 +237,7 @@ describe('governance service', () => { const multisigSub = { ...generateMultisigProposal({ id: 'sub-multisig', + pluginAddress: '0x456', pluginInterfaceType: PluginInterfaceType.MULTISIG, }), stageIndex: 0, @@ -293,6 +294,63 @@ describe('governance service', () => { ).toBeUndefined(); }); + it('getProposalBySlug enriches a sub-proposal of a lock-to-vote stage body even when its own interface type disagrees', async () => { + const tokenAddress = '0xCcCc'; + const bodyAddress = '0xBodyLtv'; + // SPP dispatches by the stage plugin's interface type, so a sub-proposal whose own + // pluginInterfaceType is inconsistent must still receive the supply enrichment. + const mismatchedSub = { + ...generateProposal({ + id: 'sub-mismatched', + pluginAddress: bodyAddress, + pluginInterfaceType: PluginInterfaceType.MULTISIG, + }), + stageIndex: 0, + }; + const proposal = generateSppProposal({ + id: '004', + network: Network.ETHEREUM_MAINNET, + pluginInterfaceType: PluginInterfaceType.SPP, + settings: generateSppPluginSettings({ + stages: [ + generateSppStage({ + plugins: [ + generateLockToVoteStagePlugin({ + address: bodyAddress.toUpperCase(), + settings: generateLockToVotePluginSettings({ + token: generateLockToVotePluginSettingsToken( + { address: tokenAddress }, + ), + }), + }), + ], + }), + ], + }), + subProposals: [mismatchedSub], + }); + const proposalParams = { + urlParams: { slug: proposal.id }, + queryParams: { daoId: 'test-id' }, + }; + requestSpy.mockResolvedValue(proposal); + fetchTokensTotalSupplySpy.mockResolvedValue({ + [tokenAddress.toLowerCase()]: '5000', + }); + + const result = + await governanceService.getProposalBySlug( + proposalParams, + ); + + const [decoratedSub] = result.subProposals; + expect( + (decoratedSub as unknown as ILockToVoteProposal).tokensTotalSupply, + ).toEqual({ + [tokenAddress.toLowerCase()]: '5000', + }); + }); + it('getProposalBySlug leaves non-LTV non-SPP proposals untouched', async () => { const proposal = generateProposal({ id: '003', diff --git a/apps/app/src/modules/governance/api/governanceService/governanceService.ts b/apps/app/src/modules/governance/api/governanceService/governanceService.ts index bbc9c95513..981e09a7cd 100644 --- a/apps/app/src/modules/governance/api/governanceService/governanceService.ts +++ b/apps/app/src/modules/governance/api/governanceService/governanceService.ts @@ -159,10 +159,23 @@ class GovernanceService extends AragonBackendService { } if (sppProposalUtils.isSppProposal(proposal)) { + // SPP evaluates a body through the STAGE plugin's interface type (see + // sppStageUtils.isBodySucceeded), so a sub-proposal needs the supply enrichment + // when either itself or its stage body is lock-to-vote — the backend does not + // guarantee the two interface types agree. + const lockToVoteBodies = new Set( + proposal.settings.stages.flatMap((stage) => + stage.plugins + .filter(lockToVoteProposalUtils.isLockToVoteStagePlugin) + .map((plugin) => plugin.address.toLowerCase()), + ), + ); + return { ...proposal, subProposals: proposal.subProposals.map((sub) => - lockToVoteProposalUtils.isLockToVoteProposal(sub) + lockToVoteProposalUtils.isLockToVoteProposal(sub) || + lockToVoteBodies.has(sub.pluginAddress.toLowerCase()) ? { ...sub, tokensTotalSupply } : sub, ), diff --git a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx index 41143ae27d..2283e13c1f 100644 --- a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx +++ b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.test.tsx @@ -66,16 +66,15 @@ describe(' component', () => { it('renders all stats with valid data and formatted relative date', () => { const proposals = [generateProposal({ blockTimestamp: 1_720_000_000 })]; - useProposalListDataSpy - .mockReturnValueOnce( - createProposalListData({ - proposalList: proposals, - itemsCount: 20, - }), - ) - .mockReturnValueOnce( - createProposalListData({ proposalList: [], itemsCount: 5 }), - ); + // Keyed on params (not call order) as the component re-renders after mount. + useProposalListDataSpy.mockImplementation((params) => + params.queryParams.isExecuted + ? createProposalListData({ proposalList: [], itemsCount: 5 }) + : createProposalListData({ + proposalList: proposals, + itemsCount: 20, + }), + ); useDaoPluginsSpy.mockReturnValue([ generateFilterComponentPlugin(), @@ -106,13 +105,9 @@ describe(' component', () => { }); it('renders "-" when data is missing', () => { - useProposalListDataSpy - .mockReturnValueOnce( - createProposalListData({ itemsCount: undefined }), - ) - .mockReturnValueOnce( - createProposalListData({ itemsCount: undefined }), - ); + useProposalListDataSpy.mockReturnValue( + createProposalListData({ itemsCount: undefined }), + ); useDaoPluginsSpy.mockReturnValue(undefined); formatDateSpy.mockReturnValue('-'); diff --git a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx index 8b5beebafe..e2065340f2 100644 --- a/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx +++ b/apps/app/src/modules/governance/components/proposalListStats/proposalListStats.tsx @@ -10,6 +10,7 @@ import type { IDao } from '@/shared/api/daoService'; import { StatCard } from '@/shared/components/statCard'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { useIsMounted } from '@/shared/hooks/useIsMounted'; import { PluginType } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; import type { IGetProposalListParams } from '../../api/governanceService'; @@ -49,14 +50,20 @@ export const ProposalListStats: React.FC = (props) => { }); const buttonUrl = daoUtils.getDaoUrl(dao, 'settings#governance'); + // Relative time is computed against "now" (and the viewer's timezone), so the server + // and client render different text — format only after mount to avoid a hydration + // mismatch; the stat shows the same placeholder as the loading state until then. + const isMounted = useIsMounted(); + const latestProposalDate = proposalList != null && proposalList.length > 0 ? proposalList[0].blockTimestamp * 1000 : undefined; - const formattedProposalDate = formatterUtils.formatDate( - latestProposalDate, - { format: DateFormat.RELATIVE }, - ); + const formattedProposalDate = isMounted + ? formatterUtils.formatDate(latestProposalDate, { + format: DateFormat.RELATIVE, + }) + : undefined; const [proposalDateValue, proposalDateUnit] = formattedProposalDate?.split( ' ', diff --git a/apps/app/src/modules/governance/hooks/useProposalPermissionCheckGuard/useProposalPermissionCheckGuard.ts b/apps/app/src/modules/governance/hooks/useProposalPermissionCheckGuard/useProposalPermissionCheckGuard.ts index c2f6f05027..614c2287f6 100644 --- a/apps/app/src/modules/governance/hooks/useProposalPermissionCheckGuard/useProposalPermissionCheckGuard.ts +++ b/apps/app/src/modules/governance/hooks/useProposalPermissionCheckGuard/useProposalPermissionCheckGuard.ts @@ -29,11 +29,13 @@ export const useProposalPermissionCheckGuard = ( const router = useRouter(); - const { meta: plugin } = useDaoPlugins({ + // The plugin is undefined when the DAO is not loaded yet or the plugin address is + // unknown (e.g. a stale link to an uninstalled process) — the guard is skipped then. + const plugin = useDaoPlugins({ daoId, pluginAddress, includeLinkedAccounts: true, - })![0]; + })?.[0]?.meta; const { data: dao } = useDao({ urlParams: { id: daoId } }); @@ -59,9 +61,13 @@ export const useProposalPermissionCheckGuard = ( const hasCalledGuardRef = useRef(false); useEffect(() => { - if (!canCreateProposal && !hasCalledGuardRef.current) { + if ( + plugin != null && + !canCreateProposal && + !hasCalledGuardRef.current + ) { hasCalledGuardRef.current = true; createProposalGuard(); } - }, [canCreateProposal, createProposalGuard]); + }, [plugin, canCreateProposal, createProposalGuard]); }; diff --git a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx index c7dff6a09a..37df4297eb 100644 --- a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx +++ b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.test.tsx @@ -221,4 +221,19 @@ describe(' component', () => { params: resumeParams, }); }); + + it('renders a not-found state instead of the wizard when the plugin address matches no DAO plugin', () => { + useDaoPluginsSpy.mockReturnValue([]); + + render( + , + ); + + expect( + screen.getByText( + 'app.governance.createProposalPage.error.notFound.title', + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId('steps-mock')).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx index 00111f990e..2bf668297d 100644 --- a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx +++ b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx @@ -1,12 +1,16 @@ 'use client'; import { useCallback, useMemo, useState } from 'react'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; +import { useDao } from '@/shared/api/daoService'; import { TransactionType } from '@/shared/api/transactionService'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; import { WizardPage } from '@/shared/components/wizards/wizardPage'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { errorUtils } from '@/shared/utils/errorUtils'; import { pendingTransactionManager } from '@/shared/utils/pendingTransactionManager'; import { plausibleAnalyticsUtils } from '@/shared/utils/plausibleAnalyticsUtils'; import { @@ -48,11 +52,18 @@ export const CreateProposalPageClient: React.FC< const { t } = useTranslations(); const { open } = useDialogContext(); - const { meta: plugin } = useDaoPlugins({ + // Undefined only when the plugin address is unknown (e.g. a stale link to an uninstalled + // process), which is why the not-found state below needs no loading guard: the route's + // wizard layout fetches the DAO and dehydrates it into this tree, so `useDao` already holds + // it on the first render — server and client alike. Should that layout ever stop + // prefetching, this branch would flash a not-found on every legitimate load. + const plugin = useDaoPlugins({ daoId, pluginAddress, includeLinkedAccounts: true, - })![0]; + })?.[0]?.meta; + + const { data: dao } = useDao({ urlParams: { id: daoId } }); useProposalPermissionCheckGuard({ daoId, @@ -77,6 +88,31 @@ export const CreateProposalPageClient: React.FC< [prepareActions, addPrepareAction, plugin], ); + const processedSteps = useMemo( + () => + createProposalWizardSteps.map((step) => ({ + ...step, + meta: { ...step.meta, name: t(step.meta.name) }, + })), + [t], + ); + + if (plugin == null) { + const pluginNotFoundError = new AragonBackendServiceError( + AragonBackendServiceError.pluginNotFoundCode, + `CreateProposalPageClient: no plugin found for address ${pluginAddress}`, + 404, + ); + + return ( + + ); + } + const handleFormSubmit = (values: ICreateProposalFormData) => { // We are always saving actions on the form so that user doesn't lose them if they navigate around the form. const { actions, addActions } = values; @@ -151,15 +187,6 @@ export const CreateProposalPageClient: React.FC< openPublishDialog(); }; - const processedSteps = useMemo( - () => - createProposalWizardSteps.map((step) => ({ - ...step, - meta: { ...step.meta, name: t(step.meta.name) }, - })), - [t], - ); - return ( = async (props) => { const queryClient = new QueryClient(); - const daoId = await daoUtils.resolveDaoId(daoPageParams); + // Bots constantly probe DAO URLs with unknown or malformed addresses — render the + // 404 page for those instead of failing the request. + const daoId = await notFoundUtils.fetchOrNotFound(() => + daoUtils.resolveDaoId(daoPageParams), + ); const daoUrlParams = { id: daoId }; - const [dao, daoOverrides, featuredDelegates] = await Promise.all([ - queryClient.fetchQuery(daoOptions({ urlParams: daoUrlParams })), - queryClient.fetchQuery(daoOverridesOptions()), - cmsService.getFeaturedDelegates(), + // Only the DAO read is addressed by the URL, so only it maps a rejected identifier onto the + // 404 page. The CMS reads answer "what does the CMS say about DAOs", not "does this DAO + // exist" — folding them in would turn a content hiccup into a not-found page. + const [dao, [daoOverrides, featuredDelegates]] = await Promise.all([ + notFoundUtils.fetchOrNotFound(() => + queryClient.fetchQuery(daoOptions({ urlParams: daoUrlParams })), + ), + Promise.all([ + queryClient.fetchQuery(daoOverridesOptions()), + cmsService.getFeaturedDelegates(), + ]), ]); const daoOverride = daoOverrides[daoId]; diff --git a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx index 7e5f897027..2873928789 100644 --- a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx +++ b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPage.tsx @@ -2,6 +2,7 @@ import { QueryClient } from '@tanstack/react-query'; import { Page } from '@/shared/components/page'; import { daoUtils } from '@/shared/utils/daoUtils'; import { errorUtils } from '@/shared/utils/errorUtils'; +import { notFoundUtils } from '@/shared/utils/notFoundUtils'; import { proposalActionsOptions, proposalBySlugOptions, @@ -21,7 +22,11 @@ export const DaoProposalDetailsPage: React.FC< > = async (props) => { const { params } = props; const { addressOrEns, network, proposalSlug } = await params; - const daoId = await daoUtils.resolveDaoId({ addressOrEns, network }); + // Bots constantly probe proposal URLs with unknown or malformed addresses — render + // the 404 page for those instead of failing the request. + const daoId = await notFoundUtils.fetchOrNotFound(() => + daoUtils.resolveDaoId({ addressOrEns, network }), + ); const queryClient = new QueryClient(); diff --git a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx index d131386311..1378e01f19 100644 --- a/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx +++ b/apps/app/src/modules/governance/pages/daoProposalDetailsPage/daoProposalDetailsPageClient.tsx @@ -29,6 +29,7 @@ import { ResourceLink } from '@/shared/components/resourceLink'; import { SafeDocumentParser } from '@/shared/components/SafeDocumentParser'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import { useIsMounted } from '@/shared/hooks/useIsMounted'; import { useSlotSingleFunction } from '@/shared/hooks/useSlotSingleFunction'; import { daoUtils } from '@/shared/utils/daoUtils'; import { @@ -135,6 +136,10 @@ export const DaoProposalDetailsPageClient: React.FC< const { data: creatorEnsName } = useEnsName(proposal?.creator.address); + // Dates are formatted in the viewer's timezone while the server renders in UTC — + // render them only after mount to avoid a hydration mismatch. + const isMounted = useIsMounted(); + if (proposal == null || dao == null) { return null; } @@ -194,12 +199,11 @@ export const DaoProposalDetailsPageClient: React.FC< proposalActions, dao, ); - const formattedCreationDate = formatterUtils.formatDate( - blockTimestamp * 1000, - { - format: DateFormat.YEAR_MONTH_DAY, - }, - ); + const formattedCreationDate = isMounted + ? formatterUtils.formatDate(blockTimestamp * 1000, { + format: DateFormat.YEAR_MONTH_DAY, + }) + : '-'; const creatorName = creatorEnsName ?? addressUtils.truncateAddress(creator.address); diff --git a/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx b/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx index 28be9ad32a..2dbfaac0a1 100644 --- a/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx +++ b/apps/app/src/modules/governance/pages/daoProposalsPage/daoProposalsPage.tsx @@ -7,6 +7,7 @@ import { type IDaoPageParams, PluginType } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; import { daoVisibilityUtils } from '@/shared/utils/daoVisibilityUtils'; import { networkUtils } from '@/shared/utils/networkUtils'; +import { notFoundUtils } from '@/shared/utils/notFoundUtils'; import { proposalListOptions } from '../../api/governanceService'; import { DaoProposalsPageClient } from './daoProposalsPageClient'; @@ -33,10 +34,16 @@ export const DaoProposalsPage: React.FC = async ( const queryClient = new QueryClient(); - const daoId = await daoUtils.resolveDaoId(daoPageParams); + // Bots constantly probe DAO URLs with unknown or malformed addresses — render the + // 404 page for those instead of failing the request. + const daoId = await notFoundUtils.fetchOrNotFound(() => + daoUtils.resolveDaoId(daoPageParams), + ); const daoUrlParams = { id: daoId }; const daoParams = { urlParams: daoUrlParams }; - const dao = await queryClient.fetchQuery(daoOptions(daoParams)); + const dao = await notFoundUtils.fetchOrNotFound(() => + queryClient.fetchQuery(daoOptions(daoParams)), + ); const daoOverrides = await queryClient.fetchQuery(daoOverridesOptions()); const daoOverride = daoOverrides[daoId]; diff --git a/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts b/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts index 3c7ebf7c39..f24f37cd53 100644 --- a/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts +++ b/apps/app/src/modules/governance/utils/governanceMetadataUtils/governanceMetadataUtils.ts @@ -46,10 +46,14 @@ class GovernanceMetadataUtils { type: 'article', }); } catch (error: unknown) { - // Suppress notFound / pluginNotFound: the page renders an empty/404 state - // for arbitrary URLs (bots, stale links to removed plugins) — not bugs, and - // would flood Sentry. - if (!AragonBackendServiceError.isExpectedNotFoundError(error)) { + // Suppress the errors that mean the URL points at nothing: the slug/address comes + // straight from the URL, so a rejected identifier means an arbitrary URL (bots, + // stale links, malformed slugs) — not a bug, and would flood Sentry. A refused + // request (401/403/429) is a different story and still gets reported. + if ( + !AragonBackendServiceError.isExpectedNotFoundError(error) && + !AragonBackendServiceError.isUnresolvableResourceError(error) + ) { monitoringUtils.logError(error); } diff --git a/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts b/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts index 2841bce4fc..cd0d8b325d 100644 --- a/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts +++ b/apps/app/src/plugins/lockToVotePlugin/types/lockToVoteProposal.ts @@ -9,7 +9,9 @@ export interface ILockToVoteProposal extends Omit { /** * Live ERC-20 totalSupply values keyed by lowercased token address. Populated by the api * service layer (`governanceService.getProposalBySlug` / `getProposalList`). Values are - * stringified bigints for SSR-safe React Query hydration. + * stringified bigints for SSR-safe React Query hydration. Optional because SPP sub-proposals + * routed by the stage plugin's interface type may miss the enrichment when the backend data + * is inconsistent — consumers must safe-parse missing values. */ - tokensTotalSupply: Record; + tokensTotalSupply?: Record; } diff --git a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts index a987a06534..b55d2b8236 100644 --- a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts +++ b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.test.ts @@ -11,7 +11,10 @@ import { } from '../../../tokenPlugin/types'; import { generateLockToVotePluginSettings } from '../../testUtils/generators/lockToVotePluginSettings'; import { generateLockToVoteProposal } from '../../testUtils/generators/lockToVoteProposal'; -import type { ILockToVoteProposal } from '../../types'; +import type { + ILockToVotePluginSettings, + ILockToVoteProposal, +} from '../../types'; import { lockToVoteProposalUtils } from './lockToVoteProposalUtils'; describe('lockToVoteProposalUtils', () => { @@ -533,6 +536,27 @@ describe('lockToVoteProposalUtils', () => { lockToVoteProposalUtils.getProposalTokenTotalSupply(proposal), ).toBeUndefined(); }); + + it('returns undefined when the enrichment is missing entirely', () => { + const proposal = generateLockToVoteProposal({ + tokensTotalSupply: undefined, + }); + expect( + lockToVoteProposalUtils.getProposalTokenTotalSupply(proposal), + ).toBeUndefined(); + }); + + it('returns undefined when the proposal carries foreign settings without a token', () => { + const proposal = generateLockToVoteProposal({ + settings: { + ...generateLockToVotePluginSettings(), + token: undefined, + } as unknown as ILockToVotePluginSettings, + }); + expect( + lockToVoteProposalUtils.getProposalTokenTotalSupply(proposal), + ).toBeUndefined(); + }); }); describe('isLockToVoteProposal', () => { diff --git a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts index b78fc59b6f..2fcf3b3a30 100644 --- a/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts +++ b/apps/app/src/plugins/lockToVotePlugin/utils/lockToVoteProposalUtils/lockToVoteProposalUtils.ts @@ -152,10 +152,17 @@ class LockToVoteProposalUtils { IDaoPlugin => plugin.interfaceType === PluginInterfaceType.LOCK_TO_VOTE; - getProposalTokenTotalSupply = (proposal: ILockToVoteProposal) => { - const tokenAddress = proposal.settings.token.address.toLowerCase(); - - return proposal.tokensTotalSupply[tokenAddress]; + getProposalTokenTotalSupply = ( + proposal: ILockToVoteProposal, + ): string | undefined => { + // SPP routes sub-proposals here based on the STAGE plugin's interface type, so the + // proposal may carry inconsistent backend data (foreign settings or missing supply + // enrichment). Returns undefined in that case; callers safe-parse the value to 0. + const tokenAddress = proposal.settings.token?.address.toLowerCase(); + + return tokenAddress != null + ? proposal.tokensTotalSupply?.[tokenAddress] + : undefined; }; } diff --git a/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts b/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts index 8fecb83889..9b8cba4a7d 100644 --- a/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts +++ b/apps/app/src/shared/api/aragonBackendService/aragonBackendServiceError.ts @@ -15,6 +15,7 @@ export class AragonBackendServiceError extends Error { constructor(code: string, description: string, status: number) { super(description); + this.name = 'AragonBackendServiceError'; this.code = code; this.description = description; this.status = status; @@ -79,4 +80,22 @@ export class AragonBackendServiceError extends Error { */ static isExpectedNotFoundError = (error: unknown) => this.isNotFoundError(error) || this.isPluginNotFoundError(error); + + /** + * True when the backend rejected the identifier the URL carries: a 404, or a 400 refusing + * the address or slug itself ("Bad parameters" — what a bot probing a malformed DAO address + * produces). For lookups addressed purely by URL parts (DAO address/ENS, proposal slug) both + * mean the URL points at nothing, so such lookups render the 404 page instead of reporting + * the failure. + * + * Deliberately not the whole 4xx range: a 401, 403 or 429 means the request was refused, not + * that the resource is gone. Folding those into a not-found would show a legitimate visitor + * the 404 page and hide a rate limit or an auth outage from Sentry. Only meaningful for + * URL-addressed reads; the same statuses on app-built requests still indicate bugs. + */ + static isUnresolvableResourceError = ( + error: unknown, + ): error is AragonBackendServiceError => + error instanceof AragonBackendServiceError && + (error.status === 400 || error.status === 404); } diff --git a/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts b/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts index 5ca1a630bb..98bf414e47 100644 --- a/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts +++ b/apps/app/src/shared/hooks/useDaoPluginInfo/useDaoPluginInfo.ts @@ -10,6 +10,7 @@ import { useTranslations } from '@/shared/components/translationsProvider'; import { daoUtils } from '@/shared/utils/daoUtils'; import { useDaoChain } from '../useDaoChain'; import { useDaoPlugins } from '../useDaoPlugins'; +import { useIsMounted } from '../useIsMounted'; export interface IUseDaoPluginInfoParams { /** @@ -43,14 +44,20 @@ export const useDaoPluginInfo = ( const { buildEntityUrl } = useDaoChain({ network: dao?.network }); + // Dates are formatted in the viewer's timezone while the server renders in UTC — + // render them only after mount to avoid a hydration mismatch. + const isMounted = useIsMounted(); + if (dao == null || plugin == null) { return settings; } const { blockTimestamp, transactionHash, release, build } = plugin.meta; - const pluginLaunchedAt = formatterUtils.formatDate(blockTimestamp * 1000, { - format: DateFormat.YEAR_MONTH, - })!; + const pluginLaunchedAt = isMounted + ? formatterUtils.formatDate(blockTimestamp * 1000, { + format: DateFormat.YEAR_MONTH, + })! + : '-'; const pluginCreationLink = buildEntityUrl({ type: ChainEntityType.TRANSACTION, diff --git a/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts b/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts index 2425c4e638..7e5b28a055 100644 --- a/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts +++ b/apps/app/src/shared/hooks/useDaoPlugins/useDaoPlugins.ts @@ -114,7 +114,12 @@ const buildFilterPlugins = ( return true; } - return daoAddress === rootDaoAddress; + // Case-insensitive: the backend may return a differently-checksummed + // daoAddress than the DAO record, which must not drop the plugin. + return ( + rootDaoAddress != null && + daoAddress.toLowerCase() === rootDaoAddress.toLowerCase() + ); }); const processedPlugins = filteredPlugins.map((plugin) => ({ diff --git a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts index 00a3240a85..13a5b13590 100644 --- a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts +++ b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.test.ts @@ -64,6 +64,33 @@ describe('monitoring utils', () => { buildEvent('TypeError: Converting circular structure to JSON'), ); expect(result).not.toBeNull(); + expect(result?.tags?.noise_class).toBeUndefined(); + }); + + it('tags environment noise (in-app browsers, extension conflicts, deploy skew) as expected/info', () => { + const environmentMessages = [ + 'TypeError: JSON.stringify cannot serialize cyclic structures.', + "TypeError: 'get' on proxy: property 'removeListener' is a read-only and non-configurable data property", + "NotFoundError: Failed to execute 'removeChild' on 'Node'", + "ReferenceError: Can't find variable: indexedDB", + 'Error: Failed to find Server Action. This request might be from an older or newer deployment.', + 'Error: The destination stream closed early.', + ]; + + environmentMessages.forEach((message) => { + const result = monitoringUtils.beforeSend(buildEvent(message)); + expect(result).not.toBeNull(); + expect(result?.tags?.noise_class).toEqual('expected'); + expect(result?.level).toEqual('info'); + }); + }); + + it('tags AppKit disconnect failures as expected wallet behaviour', () => { + const result = monitoringUtils.beforeSend( + buildEvent('AppKitError: Failed to disconnect'), + ); + expect(result).not.toBeNull(); + expect(result?.tags?.noise_class).toEqual('expected'); }); it('keeps expected wallet behaviour, tags it expected and demotes to info', () => { diff --git a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts index 33331fa656..562facec32 100644 --- a/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts +++ b/apps/app/src/shared/utils/monitoringUtils/monitoringUtils.ts @@ -73,6 +73,25 @@ class MonitoringUtils { 'replacement transaction underpriced', // Wallet has a stuck same-nonce transaction; user-resolvable "session topic doesn't exist", // WalletConnect: stale session 'No matching key. session topic', // WalletConnect: stale session + 'Failed to disconnect', // AppKit: disconnect with an already-gone wallet session + ]; + + /** + * Noise injected by the user's environment rather than our code or their actions: + * in-app-browser scripts, conflicting wallet extensions, private-mode storage, + * aborted streaming responses and deploy skew. Routed exactly like expected user + * behaviour: kept for investigation, tagged `expected`, demoted to info, out of alerts. + */ + private environmentNoisePatterns = [ + // WKWebView in-app-browser scripts JSON.stringify-ing cyclic host objects. The Chrome + // variant names the property (see the `__reactFiber` drop pattern); Safari's generic + // text is kept-but-demoted so a genuine cyclic bug of ours stays searchable. + 'cannot serialize cyclic structures', + "'get' on proxy: property 'removeListener'", // Two wallet extensions fighting over window.ethereum + "Failed to execute 'removeChild'", // Extensions/Google Translate mutating the DOM behind React + "Can't find variable: indexedDB", // Private mode / restricted WebView storage + 'Failed to find Server Action', // Deploy skew: the client bundle is older/newer than the server + 'The destination stream closed early', // Client aborted the streaming response mid-render ]; /** @@ -210,6 +229,9 @@ class MonitoringUtils { this.nonActionableExternalPatterns.some((pattern) => message.includes(pattern), ) || + this.environmentNoisePatterns.some((pattern) => + message.includes(pattern), + ) || this.hasExpectedProviderErrorCode(hint, event) ) { return 'expected'; diff --git a/apps/app/src/shared/utils/notFoundUtils/index.ts b/apps/app/src/shared/utils/notFoundUtils/index.ts new file mode 100644 index 0000000000..2a4ff6b23b --- /dev/null +++ b/apps/app/src/shared/utils/notFoundUtils/index.ts @@ -0,0 +1 @@ +export { notFoundUtils } from './notFoundUtils'; diff --git a/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts new file mode 100644 index 0000000000..33856da016 --- /dev/null +++ b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.test.ts @@ -0,0 +1,79 @@ +import { notFound } from 'next/navigation-original'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; +import { notFoundUtils } from './notFoundUtils'; + +jest.mock('next/navigation-original', () => ({ + notFound: jest.fn(() => { + throw new Error('NEXT_HTTP_ERROR_FALLBACK;404'); + }), +})); + +describe('notFoundUtils', () => { + const notFoundMock = notFound as jest.MockedFunction; + + afterEach(() => { + notFoundMock.mockClear(); + }); + + describe('fetchOrNotFound', () => { + it('returns the resolved value of the fetch callback', async () => { + const result = await notFoundUtils.fetchOrNotFound(() => + Promise.resolve('value'), + ); + expect(result).toEqual('value'); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['badParameters', 'Bad parameters', 400], + ['notFound', 'Resource not found', 404], + ])( + 'renders the 404 page when the backend rejects the identifier (%s)', + async (code, description, status) => { + const error = new AragonBackendServiceError( + code, + description, + status, + ); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toThrow('NEXT_HTTP_ERROR_FALLBACK;404'); + expect(notFoundMock).toHaveBeenCalled(); + }, + ); + + // A refused request says nothing about the URL: turning it into a 404 would show a + // legitimate visitor a not-found page and keep the outage out of Sentry. + it.each([ + ['unauthorized', 401], + ['forbidden', 403], + ['rateLimited', 429], + ])('propagates refused requests (%s)', async (code, status) => { + const error = new AragonBackendServiceError(code, code, status); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toBe(error); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + + it('propagates backend server errors', async () => { + const error = new AragonBackendServiceError( + 'serverError', + 'Internal error', + 502, + ); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toBe(error); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + + it('propagates errors that are not backend errors', async () => { + const error = new Error('network down'); + await expect( + notFoundUtils.fetchOrNotFound(() => Promise.reject(error)), + ).rejects.toBe(error); + expect(notFoundMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.ts b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.ts new file mode 100644 index 0000000000..5e86cc2fe7 --- /dev/null +++ b/apps/app/src/shared/utils/notFoundUtils/notFoundUtils.ts @@ -0,0 +1,32 @@ +// The `next/navigation` alias points to the client-hooks wrapper (src/shared/lib/nextNavigation), +// which cannot re-export server functions — import notFound from the real module instead. +import { notFound } from 'next/navigation-original'; +import { AragonBackendServiceError } from '@/shared/api/aragonBackendService'; + +class NotFoundUtils { + /** + * Runs a server-side lookup for a URL-addressed resource (DAO address/ENS, proposal slug) + * and renders the 404 page when the backend says the identifier resolves to nothing — an + * unknown resource or a malformed one, produced constantly by bots and stale links. + * Everything else — a refused request (401/403/429), a server failure, a network error — + * keeps propagating so it stays visible. + * + * Wrap the URL-addressed lookups only. A call whose failure says nothing about the URL (CMS + * content, overrides) must stay outside, or its hiccup turns the whole page into a 404. + */ + fetchOrNotFound = async ( + fetchResource: () => Promise, + ): Promise => { + try { + return await fetchResource(); + } catch (error: unknown) { + if (AragonBackendServiceError.isUnresolvableResourceError(error)) { + notFound(); + } + + throw error; + } + }; +} + +export const notFoundUtils = new NotFoundUtils();